From 81641643c39e30ba2553a9def512293c8e1a0a11 Mon Sep 17 00:00:00 2001 From: dubesinhower Date: Sat, 29 Aug 2026 18:45:56 -0400 Subject: [PATCH 01/16] fix(project): report actual generator versions --- crates/konnect-core/src/tools/project.rs | 63 +++++++++++++++++++++++- tool-directory.md | 2 +- 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/crates/konnect-core/src/tools/project.rs b/crates/konnect-core/src/tools/project.rs index dfd1cdbf..2d4dcc9f 100644 --- a/crates/konnect-core/src/tools/project.rs +++ b/crates/konnect-core/src/tools/project.rs @@ -88,7 +88,9 @@ pub fn tools() -> Vec { tool!( "get_project_info", "Read project metadata from a .kicad_pro file. Returns the project name, \ - schematic and PCB paths, and last modified times.", + schematic and PCB paths, last modified time, project-file format version, \ + and the generator versions recorded by the sibling design files. The \ + compatibility kicad_version is null when those siblings disagree.", json!({ "type": "object", "properties": { @@ -409,6 +411,18 @@ async fn handle_get_project_info( .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) .map(|d| d.as_secs()); + let schematic_generator_version = read_generator_version(&sch).await; + let pcb_generator_version = read_generator_version(&pcb).await; + let kicad_version = match ( + schematic_generator_version.as_deref(), + pcb_generator_version.as_deref(), + ) { + (Some(schematic), Some(pcb)) if schematic == pcb => Some(schematic.to_string()), + (Some(schematic), None) => Some(schematic.to_string()), + (None, Some(pcb)) => Some(pcb.to_string()), + _ => None, + }; + Ok(CallToolResult::json(&json!({ "name": stem, "path": path.display().to_string(), @@ -417,10 +431,19 @@ async fn handle_get_project_info( "pcb": pcb.display().to_string(), "pcb_exists": pcb.exists(), "last_modified_unix": modified, - "kicad_version": pro.get("meta").and_then(|m| m.get("filename")).and_then(|v| v.as_str()) + "project_file_version": pro.get("meta").and_then(|m| m.get("version")), + "schematic_generator_version": schematic_generator_version, + "pcb_generator_version": pcb_generator_version, + "kicad_version": kicad_version }))) } +async fn read_generator_version(path: &Path) -> Option { + let content = tokio::fs::read_to_string(path).await.ok()?; + let root = konnect_sexp::parse_sexp(&content).ok()?; + root.find_str("generator_version").map(str::to_owned) +} + async fn handle_snapshot_project( args: &serde_json::Value, ctx: &ToolContext, @@ -811,6 +834,42 @@ mod tests { assert_eq!(parsed["name"], "widget"); assert_eq!(parsed["schematic_exists"], true); assert_eq!(parsed["pcb_exists"], true); + assert_eq!(parsed["project_file_version"], 1); + assert_eq!(parsed["schematic_generator_version"], "10.0"); + assert_eq!(parsed["pcb_generator_version"], "10.0"); + assert_eq!(parsed["kicad_version"], "10.0"); + } + + #[tokio::test] + async fn get_project_info_does_not_claim_a_version_when_siblings_disagree() { + let dir = tempfile::tempdir().expect("tempdir"); + let ctx = test_ctx(); + let create_args = json!({ + "path": dir.path().to_str().unwrap(), + "name": "widget" + }); + handle_create_project(&create_args, &ctx) + .await + .expect("setup: create_project should succeed"); + + let pcb_path = dir.path().join("widget.kicad_pcb"); + let pcb = std::fs::read_to_string(&pcb_path).expect("read generated board"); + std::fs::write( + &pcb_path, + pcb.replace("generator_version \"10.0\"", "generator_version \"10.1\""), + ) + .expect("write board with a distinct generator version"); + + let pro_path = dir.path().join("widget.kicad_pro"); + let info_args = json!({ "path": pro_path.to_str().unwrap() }); + let result = handle_get_project_info(&info_args, &ctx) + .await + .expect("handler should succeed"); + let parsed = response_json(&result); + + assert_eq!(parsed["schematic_generator_version"], "10.0"); + assert_eq!(parsed["pcb_generator_version"], "10.1"); + assert!(parsed["kicad_version"].is_null()); } #[tokio::test] diff --git a/tool-directory.md b/tool-directory.md index 29fc7fc5..d6d6cedb 100644 --- a/tool-directory.md +++ b/tool-directory.md @@ -50,7 +50,7 @@ Six tools, grouped into *discovery/routing* and *observability*. | `create_project` | Create a new KiCAD project at the given path. Creates the directory, a blank `.kicad_pro`, empty `.kicad_sch`, and blank `.kicad_pcb`; refuses to replace any existing project file. | | `open_project` | List PCB documents open in the running KiCad UI and optionally check a specific `.kicad_pro` or `.kicad_pcb` path over IPC. | | `save_project` | Save the currently open PCB board file via KiCAD IPC. Requires KiCAD to be running with IPC enabled. | -| `get_project_info` | Read project metadata from a `.kicad_pro` file. Returns name, schematic/PCB paths, last-modified times. | +| `get_project_info` | Read project metadata from a `.kicad_pro` file. Returns name, schematic/PCB paths, last-modified time, project-file format version, and the generator versions recorded by the sibling design files. | | `rename_project` | Rename the `.kicad_pro`/`.kicad_sch`/`.kicad_pcb`/`.kicad_prl` files *and* the internal references that carry the old name. Renaming the files alone makes KiCad treat the design as unannotated, losing every reference designator, because each symbol instance stores `(project "name")`. Supports `dry_run`. | | `snapshot_project` | Export the schematic and PCB to PDF as a timestamped snapshot/checkpoint. Useful before major edits. | | `open_schematic_viewer` | Launch the live schematic viewer (SVG with auto-refresh on file change). Use after placing components so the user can see changes in real time. | From 1f4de1846ed6b02f0f8d2f0937312b43097ce26d Mon Sep 17 00:00:00 2001 From: dubesinhower Date: Sat, 29 Aug 2026 19:27:21 -0400 Subject: [PATCH 02/16] fix(sexp): preserve arc-only zone outlines --- crates/konnect-sexp/src/board.rs | 175 ++++++++++++++++++++++++++++--- 1 file changed, 160 insertions(+), 15 deletions(-) diff --git a/crates/konnect-sexp/src/board.rs b/crates/konnect-sexp/src/board.rs index 517e0cb5..dc4afa01 100644 --- a/crates/konnect-sexp/src/board.rs +++ b/crates/konnect-sexp/src/board.rs @@ -111,6 +111,22 @@ pub struct Via { pub uuid: Option, } +/// One authored element in a zone's ordered `(polygon (pts …))` path. +/// +/// KiCad writes straight corners as `(xy …)` and curved portions as exact +/// three-point `(arc (start …) (mid …) (end …))` forms. Keeping that +/// distinction avoids replacing authored curves with invented straight +/// segments. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum ZoneOutlineElement { + Point((f64, f64)), + Arc { + start: (f64, f64), + mid: (f64, f64), + end: (f64, f64), + }, +} + /// The authored outline of one `(zone …)` — the polygon the user drew, not /// the filled copper (`filled_polygon`), which refills on every pour and can /// be absent entirely on an unfilled zone. @@ -122,10 +138,17 @@ pub struct ZoneOutline { /// Layers the zone lives on: KiCad 10 writes `(layers …)` (plural), older /// single-layer zones `(layer …)`. Both shapes land here. pub layers: Vec, - /// Outline vertices of the zone's first `(polygon (pts …))`, in file - /// order. Always ≥ 3 points — fewer cannot enclose area, so such a zone - /// is skipped. + /// Every finite coordinate defining the zone's first + /// `(polygon (pts …))`, in file order. An `(xy …)` contributes one point; + /// an arc contributes its start, mid, and end. Always ≥ 3 points — fewer + /// cannot enclose area, so such a zone is skipped. + /// + /// This retains the original point-only view for straight polygons. Use + /// [`Self::elements`] when the distinction between lines and arcs matters. pub points: Vec<(f64, f64)>, + /// Lossless ordered outline geometry. Unknown or incomplete elements make + /// the zone unreadable rather than being silently dropped. + pub elements: Vec, } /// Every routed track segment on the board. @@ -233,14 +256,25 @@ pub fn zones(tree: &SexpNode) -> Scan { return None; } let pts = zone.find("polygon")?.find("pts")?; - let points: Vec<(f64, f64)> = pts - .find_all("xy") - .into_iter() - .map(|xy| { - let (x, y) = (xy.get_f64(1)?, xy.get_f64(2)?); - (x.is_finite() && y.is_finite()).then_some((x, y)) - }) - .collect::>>()?; + let mut points = Vec::new(); + let mut elements = Vec::new(); + for node in pts.children()?.iter().skip(1) { + match node.head()? { + "xy" => { + let point = coordinate_pair(node)?; + points.push(point); + elements.push(ZoneOutlineElement::Point(point)); + } + "arc" => { + let start = point(node, "start")?; + let mid = point(node, "mid")?; + let end = point(node, "end")?; + points.extend([start, mid, end]); + elements.push(ZoneOutlineElement::Arc { start, mid, end }); + } + _ => return None, + } + } if points.len() < 3 { return None; // fewer than 3 vertices encloses no area } @@ -248,6 +282,7 @@ pub fn zones(tree: &SexpNode) -> Scan { net: resolve_net(zone, &table), layers, points, + elements, }) })(); match parsed { @@ -332,12 +367,15 @@ fn graphic_bbox(node: &SexpNode, head: &str) -> Option<(f64, f64, f64, f64)> { /// `(tag x y)` as a finite coordinate pair, or `None` — never a zero-filled /// stand-in (see the module docs). -fn point(node: &SexpNode, tag: &str) -> Option<(f64, f64)> { - let p = node.find(tag)?; - let (x, y) = (p.get_f64(1)?, p.get_f64(2)?); +fn coordinate_pair(node: &SexpNode) -> Option<(f64, f64)> { + let (x, y) = (node.get_f64(1)?, node.get_f64(2)?); (x.is_finite() && y.is_finite()).then_some((x, y)) } +fn point(node: &SexpNode, tag: &str) -> Option<(f64, f64)> { + coordinate_pair(node.find(tag)?) +} + /// The board's top-level net table (`(net N "NAME")` direct children), which /// only KiCad ≤ 9 writes. Keys are the numeric ids as written. fn top_level_net_table(tree: &SexpNode) -> HashMap { @@ -949,6 +987,49 @@ mod tests { \t)\n\ )"; + /// Reduced from the two arc-only zone outlines in KiCad 10's + /// RoyalBlue54L-Feather demo board. The full board is intentionally not a + /// fixture: these are the only authored forms the zone scan rejected. + const KICAD10_ARC_ONLY_ZONES: &str = "(kicad_pcb\n\ + \t(version 20260206)\n\ + \t(generator \"pcbnew\")\n\ + \t(net 15 \"+BATT\")\n\ + \t(net 35 \"VSYS\")\n\ + \t(zone\n\ + \t\t(net 35)\n\ + \t\t(net_name \"VSYS\")\n\ + \t\t(layer \"B.Cu\")\n\ + \t\t(polygon\n\ + \t\t\t(pts\n\ + \t\t\t\t(arc (start 149.28 105.91) (mid 149.206777 106.086777) (end 149.03 106.16))\n\ + \t\t\t\t(arc (start 147.13 106.16) (mid 146.953223 106.233223) (end 146.88 106.41))\n\ + \t\t\t\t(arc (start 146.88 108.81) (mid 146.953223 108.986777) (end 147.13 109.06))\n\ + \t\t\t\t(arc (start 149.93 109.06) (mid 150.106777 108.986777) (end 150.18 108.81))\n\ + \t\t\t\t(arc (start 150.18 99.41) (mid 150.106777 99.233223) (end 149.93 99.16))\n\ + \t\t\t\t(arc (start 147.83 99.16) (mid 147.653223 99.233223) (end 147.58 99.41))\n\ + \t\t\t\t(arc (start 147.58 102.61) (mid 147.653223 102.786777) (end 147.83 102.86))\n\ + \t\t\t\t(arc (start 149.03 102.86) (mid 149.206777 102.933223) (end 149.28 103.11))\n\ + \t\t\t)\n\ + \t\t)\n\ + \t)\n\ + \t(zone\n\ + \t\t(net 15)\n\ + \t\t(net_name \"+BATT\")\n\ + \t\t(layer \"In2.Cu\")\n\ + \t\t(polygon\n\ + \t\t\t(pts\n\ + \t\t\t\t(arc (start 144.26 95.23) (mid 144.552893 95.937107) (end 145.26 96.23))\n\ + \t\t\t\t(arc (start 148.545786 96.23) (mid 148.928469 96.30612) (end 149.252893 96.522893))\n\ + \t\t\t\t(arc (start 150.167107 97.437107) (mid 150.38388 97.76153) (end 150.46 98.144214))\n\ + \t\t\t\t(arc (start 150.46 102.13) (mid 150.167107 102.837107) (end 149.46 103.13))\n\ + \t\t\t\t(arc (start 138.36 103.13) (mid 137.652893 102.837107) (end 137.36 102.13))\n\ + \t\t\t\t(arc (start 137.36 94.73) (mid 137.652893 94.022893) (end 138.36 93.73))\n\ + \t\t\t\t(arc (start 143.26 93.73) (mid 143.967107 94.022893) (end 144.26 94.73))\n\ + \t\t\t)\n\ + \t\t)\n\ + \t)\n\ + )"; + #[test] fn tracks_resolve_numeric_nets_through_the_table() { let tree = parse_sexp(KICAD9_BOARD).unwrap(); @@ -1017,6 +1098,15 @@ mod tests { (122.555, 135.89), ] ); + assert_eq!( + z.elements, + vec![ + ZoneOutlineElement::Point((172.085, 135.89)), + ZoneOutlineElement::Point((172.085, 91.313)), + ZoneOutlineElement::Point((122.555, 91.44)), + ZoneOutlineElement::Point((122.555, 135.89)), + ] + ); let k10 = parse_sexp(KICAD10_BOARD).unwrap(); assert_eq!(zones(&k10).items[0].net.as_deref(), Some("GND")); @@ -1034,6 +1124,55 @@ mod tests { assert_eq!(scan.items[0].layers, vec!["F.Cu", "B.Cu"]); } + #[test] + fn kicad_10_arc_only_zone_outlines_scan_losslessly() { + let tree = parse_sexp(KICAD10_ARC_ONLY_ZONES).unwrap(); + let scan = zones(&tree); + + assert_eq!(scan.skipped, 0); + assert_eq!(scan.items.len(), 2); + assert_eq!(scan.items[0].net.as_deref(), Some("VSYS")); + assert_eq!(scan.items[0].layers, vec!["B.Cu"]); + assert_eq!(scan.items[0].points.len(), 24); + assert_eq!(scan.items[0].elements.len(), 8); + assert_eq!( + scan.items[0].elements.first(), + Some(&ZoneOutlineElement::Arc { + start: (149.28, 105.91), + mid: (149.206777, 106.086777), + end: (149.03, 106.16), + }) + ); + assert_eq!( + scan.items[0].elements.last(), + Some(&ZoneOutlineElement::Arc { + start: (149.03, 102.86), + mid: (149.206777, 102.933223), + end: (149.28, 103.11), + }) + ); + assert_eq!(scan.items[1].net.as_deref(), Some("+BATT")); + assert_eq!(scan.items[1].layers, vec!["In2.Cu"]); + assert_eq!(scan.items[1].points.len(), 21); + assert_eq!(scan.items[1].elements.len(), 7); + assert_eq!( + scan.items[1].elements.first(), + Some(&ZoneOutlineElement::Arc { + start: (144.26, 95.23), + mid: (144.552893, 95.937107), + end: (145.26, 96.23), + }) + ); + assert_eq!( + scan.items[1].elements.last(), + Some(&ZoneOutlineElement::Arc { + start: (143.26, 93.73), + mid: (143.967107, 94.022893), + end: (144.26, 94.73), + }) + ); + } + /// The module's malformed-item policy: broken nodes are dropped *and /// counted*, and are never zero-filled into phantom copper at the origin. #[test] @@ -1045,6 +1184,10 @@ mod tests { \t(segment (start 0 0) (end 1 0) (width 0.5) (layer \"F.Cu\"))\n\ \t(via (at 1 1) (size 0.8) (layers \"F.Cu\" \"B.Cu\"))\n\ \t(zone (layer \"F.Cu\") (polygon (pts (xy 0 0) (xy 1 0))))\n\ + \t(zone (layer \"F.Cu\") (polygon (pts\n\ + \t\t(arc (start 0 0) (mid 1 1)))))\n\ + \t(zone (layer \"F.Cu\") (polygon (pts\n\ + \t\t(xy 0 0) (curve (start 1 0) (end 1 1)) (xy 0 1))))\n\ )", ) .unwrap(); @@ -1053,7 +1196,9 @@ mod tests { let v = vias(&tree); assert_eq!((v.items.len(), v.skipped), (0, 1)); // no drill let z = zones(&tree); - assert_eq!((z.items.len(), z.skipped), (0, 1)); // 2 points enclose nothing + assert_eq!((z.items.len(), z.skipped), (0, 3)); + // Too few points, an incomplete arc, and an unknown outline element + // are all unreadable rather than being fabricated or dropped. } #[test] From a4a9b0b9e16e2d9983a31fc35c7027f30848097f Mon Sep 17 00:00:00 2001 From: dubesinhower Date: Sat, 29 Aug 2026 19:31:26 -0400 Subject: [PATCH 03/16] fix(sexp): keep point-only zone view stable --- crates/konnect-sexp/src/board.rs | 23 ++++++++++---------- crates/konnect-sexp/tests/proptest_parser.rs | 14 ++++++++++-- 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/crates/konnect-sexp/src/board.rs b/crates/konnect-sexp/src/board.rs index dc4afa01..ba64fac4 100644 --- a/crates/konnect-sexp/src/board.rs +++ b/crates/konnect-sexp/src/board.rs @@ -138,13 +138,10 @@ pub struct ZoneOutline { /// Layers the zone lives on: KiCad 10 writes `(layers …)` (plural), older /// single-layer zones `(layer …)`. Both shapes land here. pub layers: Vec, - /// Every finite coordinate defining the zone's first - /// `(polygon (pts …))`, in file order. An `(xy …)` contributes one point; - /// an arc contributes its start, mid, and end. Always ≥ 3 points — fewer - /// cannot enclose area, so such a zone is skipped. - /// - /// This retains the original point-only view for straight polygons. Use - /// [`Self::elements`] when the distinction between lines and arcs matters. + /// Straight `(xy …)` vertices from the zone's first + /// `(polygon (pts …))`, in file order. This retains the original + /// point-only view; it is empty for an arc-only outline. Use + /// [`Self::elements`] for the complete geometry. pub points: Vec<(f64, f64)>, /// Lossless ordered outline geometry. Unknown or incomplete elements make /// the zone unreadable rather than being silently dropped. @@ -258,25 +255,27 @@ pub fn zones(tree: &SexpNode) -> Scan { let pts = zone.find("polygon")?.find("pts")?; let mut points = Vec::new(); let mut elements = Vec::new(); + let mut defining_coordinate_count = 0usize; for node in pts.children()?.iter().skip(1) { match node.head()? { "xy" => { let point = coordinate_pair(node)?; points.push(point); elements.push(ZoneOutlineElement::Point(point)); + defining_coordinate_count += 1; } "arc" => { let start = point(node, "start")?; let mid = point(node, "mid")?; let end = point(node, "end")?; - points.extend([start, mid, end]); elements.push(ZoneOutlineElement::Arc { start, mid, end }); + defining_coordinate_count += 3; } _ => return None, } } - if points.len() < 3 { - return None; // fewer than 3 vertices encloses no area + if defining_coordinate_count < 3 { + return None; // fewer than 3 coordinates cannot enclose area } Some(ZoneOutline { net: resolve_net(zone, &table), @@ -1133,7 +1132,7 @@ mod tests { assert_eq!(scan.items.len(), 2); assert_eq!(scan.items[0].net.as_deref(), Some("VSYS")); assert_eq!(scan.items[0].layers, vec!["B.Cu"]); - assert_eq!(scan.items[0].points.len(), 24); + assert!(scan.items[0].points.is_empty()); assert_eq!(scan.items[0].elements.len(), 8); assert_eq!( scan.items[0].elements.first(), @@ -1153,7 +1152,7 @@ mod tests { ); assert_eq!(scan.items[1].net.as_deref(), Some("+BATT")); assert_eq!(scan.items[1].layers, vec!["In2.Cu"]); - assert_eq!(scan.items[1].points.len(), 21); + assert!(scan.items[1].points.is_empty()); assert_eq!(scan.items[1].elements.len(), 7); assert_eq!( scan.items[1].elements.first(), diff --git a/crates/konnect-sexp/tests/proptest_parser.rs b/crates/konnect-sexp/tests/proptest_parser.rs index c7fb2d69..e9d221e6 100644 --- a/crates/konnect-sexp/tests/proptest_parser.rs +++ b/crates/konnect-sexp/tests/proptest_parser.rs @@ -8,7 +8,9 @@ //! `Err`, not a crash, because tool handlers feed it user files verbatim. //! 2. Parsing is total and deterministic over well-formed input. -use konnect_sexp::{parse_sexp, writer::apply_edits, SexpEdit, SexpNode}; +use konnect_sexp::{ + board::ZoneOutlineElement, parse_sexp, writer::apply_edits, SexpEdit, SexpNode, +}; use proptest::prelude::*; // ─── Strategies ────────────────────────────────────────────────────────────── @@ -155,7 +157,15 @@ proptest! { prop_assert!(!v.layers.is_empty()); } for z in &konnect_sexp::board::zones(&tree).items { - prop_assert!(z.points.len() >= 3); + let defining_coordinate_count: usize = z + .elements + .iter() + .map(|element| match element { + ZoneOutlineElement::Point(_) => 1, + ZoneOutlineElement::Arc { .. } => 3, + }) + .sum(); + prop_assert!(defining_coordinate_count >= 3); prop_assert!(!z.layers.is_empty()); } if let Some((x0, y0, x1, y1)) = konnect_sexp::board::board_outline_bbox(&tree) { From d565be32ca3f900fd2b5cca3a44dc7f54209a601 Mon Sep 17 00:00:00 2001 From: dubesinhower Date: Sat, 29 Aug 2026 20:03:10 -0400 Subject: [PATCH 04/16] fix(pcb): preserve footprint properties during refresh --- .../konnect-core/src/tools/pcb_components.rs | 20 +- .../src/tools/pcb_footprint_update.rs | 686 +++++++++++++++++- .../tests/fixtures/socket_kicad10.kicad_mod | 23 + 3 files changed, 722 insertions(+), 7 deletions(-) diff --git a/crates/konnect-core/src/tools/pcb_components.rs b/crates/konnect-core/src/tools/pcb_components.rs index 0e003375..dc08223f 100644 --- a/crates/konnect-core/src/tools/pcb_components.rs +++ b/crates/konnect-core/src/tools/pcb_components.rs @@ -409,6 +409,19 @@ pub(crate) fn extract_field_placement(source: &str) -> konnect_ipc::IpcFieldPlac pub(crate) fn extract_graphic_definitions( source: &str, +) -> anyhow::Result> { + extract_graphic_definitions_with_properties(source, true) +} + +pub(crate) fn extract_graphic_definitions_without_properties( + source: &str, +) -> anyhow::Result> { + extract_graphic_definitions_with_properties(source, false) +} + +fn extract_graphic_definitions_with_properties( + source: &str, + include_properties: bool, ) -> anyhow::Result> { use konnect_ipc::IpcGraphicDefinition as Graphic; let footprint = konnect_sexp::parse_sexp(source)?; @@ -493,7 +506,12 @@ pub(crate) fn extract_graphic_definitions( stroke_width_mm: text_stroke_width(text), }); } - for property in footprint.find_all("property") { + let properties = if include_properties { + footprint.find_all("property") + } else { + Vec::new() + }; + for property in properties { let name = property.get(1).and_then(konnect_sexp::SexpNode::as_str); // Reference and Value travel as first-class fields; hidden built-ins // (Footprint, Datasheet, …) are not drawn. diff --git a/crates/konnect-core/src/tools/pcb_footprint_update.rs b/crates/konnect-core/src/tools/pcb_footprint_update.rs index 7a7b75a9..b7d6f2d3 100644 --- a/crates/konnect-core/src/tools/pcb_footprint_update.rs +++ b/crates/konnect-core/src/tools/pcb_footprint_update.rs @@ -20,6 +20,7 @@ struct LibraryFootprint { attributes: kiapi::board::types::FootprintAttributes, datasheet: Option, description_field: Option, + properties: Vec, pads: Vec, graphics: Vec, models: Vec, @@ -733,11 +734,14 @@ fn parse_library_footprint(library_id: &str, source: &str) -> Result Result Option { .map(str::to_string) } +fn parse_custom_properties( + root: &konnect_sexp::SexpNode, +) -> Result> { + let mut names = BTreeSet::new(); + root.find_all("property") + .into_iter() + .filter_map(|property| { + let name = property.get(1).and_then(konnect_sexp::SexpNode::as_str)?; + (!matches!(name, "Reference" | "Value" | "Datasheet" | "Description")) + .then_some((name, property)) + }) + .map(|(name, property)| { + if !names.insert(name.to_string()) { + bail!("property '{name}' appears more than once in the library footprint"); + } + parse_custom_property(property) + }) + .collect() +} + +/// Convert a non-mandatory footprint property into the typed `Field` item +/// carried by KiCad's IPC model. Unknown clauses refuse here: accepting a +/// property while dropping part of its authored presentation would make the +/// refresh lossy even when the value itself happened to survive. +fn parse_custom_property(property: &konnect_sexp::SexpNode) -> Result { + use kiapi::common::types::LockedState; + + let name = property + .get(1) + .and_then(konnect_sexp::SexpNode::as_str) + .context("property is missing its name")?; + let value = property + .get(2) + .and_then(konnect_sexp::SexpNode::as_str) + .with_context(|| format!("property '{name}' is missing its value"))?; + let mut position = None; + let mut rotation = 0.0; + let mut layer = None; + let mut visible = true; + let mut knockout = false; + let mut attributes = None; + let mut identifier = None; + + for clause in property.children().unwrap_or_default().iter().skip(3) { + let tag = clause + .head() + .with_context(|| format!("property '{name}' contains an unsupported atom"))?; + match tag { + "at" => { + if position.is_some() { + bail!("property '{name}' contains duplicate 'at' clauses"); + } + let count = clause.children().map_or(0, |children| children.len()); + if !matches!(count, 3 | 4) { + bail!("property '{name}' 'at' must contain x, y, and optional rotation"); + } + let x = clause + .get_f64(1) + .with_context(|| format!("property '{name}' has an invalid X position"))?; + let y = clause + .get_f64(2) + .with_context(|| format!("property '{name}' has an invalid Y position"))?; + rotation = clause.get_f64(3).unwrap_or(0.0); + if !x.is_finite() || !y.is_finite() || !rotation.is_finite() { + bail!("property '{name}' position and rotation must be finite"); + } + position = Some(konnect_ipc::builders::vec2(x, y)); + } + "layer" => { + if layer.is_some() { + bail!("property '{name}' contains duplicate 'layer' clauses"); + } + let layer_name = clause + .get(1) + .and_then(konnect_sexp::SexpNode::as_str) + .filter(|name| !name.is_empty()) + .with_context(|| format!("property '{name}' has no layer name"))?; + if clause.children().map_or(0, |children| children.len()) != 2 { + bail!("property '{name}' 'layer' must name exactly one layer"); + } + layer = Some( + konnect_ipc::builders::try_layer_from_name(layer_name) + .with_context(|| format!("property '{name}' has an unsupported layer"))? + as i32, + ); + } + "hide" => visible = !property_yes_no(clause, name, "hide")?, + "knockout" => knockout = property_yes_no(clause, name, "knockout")?, + "uuid" | "tstamp" => { + if let Some(previous) = identifier { + bail!( + "property '{name}' contains multiple identifier clauses ('{previous}' and '{tag}')" + ); + } + identifier = Some(tag); + if clause.children().map_or(0, |children| children.len()) != 2 + || clause + .get(1) + .and_then(konnect_sexp::SexpNode::as_str) + .is_none_or(str::is_empty) + { + bail!("property '{name}' '{tag}' must contain exactly one identifier"); + } + } + "effects" => { + if attributes.is_some() { + bail!("property '{name}' contains duplicate 'effects' clauses"); + } + attributes = Some(parse_property_effects(clause, name)?); + } + unsupported => { + bail!("property '{name}' clause '{unsupported}' is not supported losslessly") + } + } + } + + let position = + position.with_context(|| format!("property '{name}' is missing its 'at' clause"))?; + let layer = + layer.with_context(|| format!("property '{name}' is missing its 'layer' clause"))?; + let mut attributes = + attributes.with_context(|| format!("property '{name}' is missing its 'effects' clause"))?; + attributes.angle = Some(kiapi::common::types::Angle { + value_degrees: rotation, + }); + Ok(kiapi::board::types::Field { + id: None, + name: name.to_string(), + text: Some(kiapi::board::types::BoardText { + // A library child's UUID is definition-local and cannot be reused + // across placed instances. Let KiCad assign the board child ID. + id: None, + text: Some(kiapi::common::types::Text { + position: Some(position), + attributes: Some(attributes), + text: value.to_string(), + hyperlink: String::new(), + }), + layer, + knockout, + locked: LockedState::LsUnlocked as i32, + }), + visible, + }) +} + +fn property_yes_no(clause: &konnect_sexp::SexpNode, name: &str, tag: &str) -> Result { + if clause.children().map_or(0, |children| children.len()) != 2 { + bail!("property '{name}' '{tag}' must contain exactly one yes/no value"); + } + match clause.get(1).and_then(konnect_sexp::SexpNode::as_str) { + Some("yes") => Ok(true), + Some("no") => Ok(false), + _ => bail!("property '{name}' '{tag}' must be yes or no"), + } +} + +fn parse_property_effects( + effects: &konnect_sexp::SexpNode, + name: &str, +) -> Result { + use kiapi::common::types::{HorizontalAlignment, VerticalAlignment}; + + let mut font = None; + let mut horizontal = HorizontalAlignment::HaCenter; + let mut vertical = VerticalAlignment::VaCenter; + let mut mirrored = false; + for clause in effects.children().unwrap_or_default().iter().skip(1) { + let tag = clause + .head() + .with_context(|| format!("property '{name}' effects contain an unsupported atom"))?; + match tag { + "font" => { + if font.replace(clause).is_some() { + bail!("property '{name}' contains duplicate font clauses"); + } + } + "justify" => { + for value in clause.children().unwrap_or_default().iter().skip(1) { + match value.as_str().with_context(|| { + format!("property '{name}' justify contains a non-atom") + })? { + "left" if horizontal == HorizontalAlignment::HaCenter => { + horizontal = HorizontalAlignment::HaLeft + } + "right" if horizontal == HorizontalAlignment::HaCenter => { + horizontal = HorizontalAlignment::HaRight + } + "top" if vertical == VerticalAlignment::VaCenter => { + vertical = VerticalAlignment::VaTop + } + "bottom" if vertical == VerticalAlignment::VaCenter => { + vertical = VerticalAlignment::VaBottom + } + "mirror" if !mirrored => mirrored = true, + "left" | "right" => bail!( + "property '{name}' has conflicting horizontal justification" + ), + "top" | "bottom" => { + bail!("property '{name}' has conflicting vertical justification") + } + "mirror" => bail!("property '{name}' repeats mirrored justification"), + unsupported => bail!( + "property '{name}' justification '{unsupported}' is not supported losslessly" + ), + } + } + } + unsupported => bail!( + "property '{name}' effects clause '{unsupported}' is not supported losslessly" + ), + } + } + let font = + font.with_context(|| format!("property '{name}' effects are missing the font clause"))?; + + let mut font_name = String::new(); + let mut size = None; + let mut thickness = None; + let mut bold = false; + let mut italic = false; + let mut line_spacing = 1.0; + for clause in font.children().unwrap_or_default().iter().skip(1) { + let tag = clause + .head() + .with_context(|| format!("property '{name}' font contains an unsupported atom"))?; + match tag { + "face" => { + if !font_name.is_empty() { + bail!("property '{name}' contains duplicate font face clauses"); + } + font_name = clause + .get(1) + .and_then(konnect_sexp::SexpNode::as_str) + .filter(|face| !face.is_empty()) + .with_context(|| format!("property '{name}' font face is invalid"))? + .to_string(); + } + "size" => { + if size.is_some() { + bail!("property '{name}' contains duplicate font size clauses"); + } + if clause.children().map_or(0, |children| children.len()) != 3 { + bail!("property '{name}' font size must contain width and height"); + } + let width = clause + .get_f64(1) + .with_context(|| format!("property '{name}' font width is invalid"))?; + let height = clause + .get_f64(2) + .with_context(|| format!("property '{name}' font height is invalid"))?; + if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 { + bail!("property '{name}' font size must be finite and positive"); + } + size = Some((width, height)); + } + "thickness" => { + if thickness.is_some() { + bail!("property '{name}' contains duplicate font thickness clauses"); + } + let value = clause + .get_f64(1) + .with_context(|| format!("property '{name}' font thickness is invalid"))?; + if clause.children().map_or(0, |children| children.len()) != 2 + || !value.is_finite() + || value <= 0.0 + { + bail!("property '{name}' font thickness must be finite and positive"); + } + thickness = Some(value); + } + "bold" => bold = property_yes_no(clause, name, "font bold")?, + "italic" => italic = property_yes_no(clause, name, "font italic")?, + "line_spacing" => { + let value = clause + .get_f64(1) + .with_context(|| format!("property '{name}' line spacing is invalid"))?; + if clause.children().map_or(0, |children| children.len()) != 2 + || !value.is_finite() + || value <= 0.0 + { + bail!("property '{name}' line spacing must be finite and positive"); + } + line_spacing = value; + } + unsupported => { + bail!("property '{name}' font clause '{unsupported}' is not supported losslessly") + } + } + } + let (width, height) = + size.with_context(|| format!("property '{name}' font is missing its size"))?; + Ok(kiapi::common::types::TextAttributes { + font_name, + horizontal_alignment: horizontal as i32, + vertical_alignment: vertical as i32, + angle: None, + line_spacing, + stroke_width: Some(konnect_ipc::builders::distance( + thickness.unwrap_or(width * 0.15), + )), + italic, + bold, + underlined: false, + visible: true, + mirrored, + multiline: false, + keep_upright: false, + size: Some(konnect_ipc::builders::vec2(width, height)), + }) +} + fn validate_supported_children(root: &konnect_sexp::SexpNode) -> Result<()> { for child in root.children().unwrap_or_default().iter().skip(2) { let Some(tag) = child.head() else { @@ -824,7 +1141,7 @@ fn validate_supported_children(root: &konnect_sexp::SexpNode) -> Result<()> { .and_then(konnect_sexp::SexpNode::as_str) .context("property is missing its name")?; if !matches!(name, "Reference" | "Value" | "Datasheet" | "Description") { - bail!("property '{name}' is not supported losslessly by typed library refresh"); + parse_custom_property(child)?; } } _ => {} @@ -1338,6 +1655,14 @@ fn build_updated_instance( *item = konnect_ipc::builders::pack_any(&text, "kiapi.board.types.BoardText"); } } + merge_custom_properties( + &mut definition, + current_definition, + &library.properties, + &position, + rotation, + is_back, + )?; updated.definition = Some(definition); apply_field_value(&mut updated.datasheet_field, library.datasheet.as_deref()); apply_field_value( @@ -1373,6 +1698,124 @@ fn apply_field_value(field: &mut Option, value: Opti .text = value.to_string(); } +fn merge_custom_properties( + updated: &mut kiapi::board::types::Footprint, + current: &kiapi::board::types::Footprint, + library_properties: &[kiapi::board::types::Field], + footprint_position: &kiapi::common::types::Vector2, + footprint_rotation: f64, + is_back: bool, +) -> Result<()> { + let library_names = library_properties + .iter() + .map(|field| field.name.as_str()) + .collect::>(); + let mut current_names = BTreeSet::new(); + for item in current + .items + .iter() + .filter(|item| item.type_url.ends_with("kiapi.board.types.Field")) + { + let field = kiapi::board::types::Field::decode(item.value.as_slice()) + .context("board footprint contains an invalid custom property")?; + if field.name.is_empty() { + bail!("board footprint contains a custom property without a name"); + } + if !current_names.insert(field.name.clone()) { + bail!( + "board footprint contains more than one custom property named '{}'", + field.name + ); + } + if !library_names.contains(field.name.as_str()) { + // A field that exists only on the placed instance belongs to that + // instance. Preserve its complete typed representation verbatim. + updated.items.push(item.clone()); + } + } + for property in library_properties { + let property = + transform_library_property(property, footprint_position, footprint_rotation, is_back)?; + updated.items.push(konnect_ipc::builders::pack_any( + &property, + "kiapi.board.types.Field", + )); + } + Ok(()) +} + +fn transform_library_property( + property: &kiapi::board::types::Field, + footprint_position: &kiapi::common::types::Vector2, + footprint_rotation: f64, + is_back: bool, +) -> Result { + let mut property = property.clone(); + property.id = None; + let board_text = property + .text + .as_mut() + .with_context(|| format!("property '{}' has no board text", property.name))?; + board_text.id = None; + let text = board_text + .text + .as_mut() + .with_context(|| format!("property '{}' has no text value", property.name))?; + let local_position = text + .position + .as_ref() + .with_context(|| format!("property '{}' has no position", property.name))?; + let local_x = konnect_ipc::builders::nm_to_mm(local_position.x_nm); + let mut local_y = konnect_ipc::builders::nm_to_mm(local_position.y_nm); + if is_back { + local_y = -local_y; + } + let (board_x, board_y) = konnect_sexp::geometry::transform_pad( + local_x, + local_y, + konnect_ipc::builders::nm_to_mm(footprint_position.x_nm), + konnect_ipc::builders::nm_to_mm(footprint_position.y_nm), + footprint_rotation, + ); + text.position = Some(konnect_ipc::builders::vec2(board_x, board_y)); + + let attributes = text + .attributes + .as_mut() + .with_context(|| format!("property '{}' has no text attributes", property.name))?; + let local_angle = attributes + .angle + .as_ref() + .map(|angle| angle.value_degrees) + .unwrap_or(0.0); + let local_angle = if is_back { + 180.0 - local_angle + } else { + local_angle + }; + attributes.angle = Some(kiapi::common::types::Angle { + value_degrees: readable_property_angle(local_angle + footprint_rotation), + }); + if is_back { + attributes.mirrored = !attributes.mirrored; + let layer = kiapi::board::types::BoardLayer::try_from(board_text.layer) + .with_context(|| format!("property '{}' has an invalid layer", property.name))?; + let layer_name = konnect_ipc::builders::layer_name(layer) + .with_context(|| format!("property '{}' has an unnamed layer", property.name))?; + board_text.layer = + konnect_ipc::builders::try_layer_from_name(&flip_layer_name(layer_name)?)? as i32; + } + Ok(property) +} + +fn readable_property_angle(degrees: f64) -> f64 { + let mut angle = degrees.rem_euclid(360.0); + if angle > 90.0 && angle <= 270.0 { + angle -= 180.0; + } + angle +} + fn mirror_pad(pad: &konnect_ipc::IpcPadDefinition) -> Result { let mut mirrored = pad.clone(); mirrored.y = -mirrored.y; @@ -1554,6 +1997,8 @@ fn changed_domains( != field_text(&updated_definition.datasheet_field) || field_text(¤t_definition.description_field) != field_text(&updated_definition.description_field) + || normalized_items(current_definition, "Field")? + != normalized_items(updated_definition, "Field")? { changed.insert(ChangedDomain::Metadata); } @@ -1602,6 +2047,13 @@ fn normalized_items( } } Ok(pad.encode_to_vec()) + } else if suffix == "Field" { + let mut field = kiapi::board::types::Field::decode(item.value.as_slice())?; + field.id = None; + if let Some(text) = field.text.as_mut() { + text.id = None; + } + Ok(field.encode_to_vec()) } else { Ok(item.value.clone()) } @@ -1778,7 +2230,7 @@ mod tests { /// CONTRIBUTING). #[test] fn a_footprint_kicad_saved_is_accepted_not_refused() { - let source = include_str!("../../tests/fixtures/socket_kicad10.kicad_mod"); + let source = KICAD_LIBRARY_FOOTPRINT; let library = parse_library_footprint("Konnect:Socket", source) .expect("KiCad's own serialization of the fixture must parse"); assert_eq!(library.pads.len(), 4, "two '1' variants plus '2' and '3'"); @@ -1806,6 +2258,18 @@ mod tests { ) })); assert_eq!(library.datasheet.as_deref(), Some("new-datasheet.pdf")); + assert_eq!( + library + .properties + .iter() + .map(|property| (property.name.as_str(), field_text_value(property))) + .collect::>(), + vec![ + ("KiLib_Generator", "konnect_test_generator"), + ("AssemblyVendor", "Example Assembly"), + ] + ); + assert!(library.properties.iter().all(|property| !property.visible)); assert_eq!(library.models.len(), 1); // The same flags at a non-default value carry semantics the typed @@ -1826,6 +2290,45 @@ mod tests { } } + #[test] + fn visible_property_is_a_field_only_and_clause_order_does_not_change_its_angle() { + let source = KICAD_LIBRARY_FOOTPRINT.replace( + "\t\t(at 0.5 0.75 15)\n\t\t(layer \"F.Fab\")\n\t\t(hide yes)", + "\t\t(layer \"F.Fab\")\n\t\t(hide no)\n\t\t(at 0.5 0.75 15)", + ); + assert_ne!(source, KICAD_LIBRARY_FOOTPRINT); + + let library = parse_library_footprint("Konnect:Socket", &source).unwrap(); + let property = library + .properties + .iter() + .find(|property| property.name == "AssemblyVendor") + .unwrap(); + assert!(property.visible); + assert_eq!( + property + .text + .as_ref() + .unwrap() + .text + .as_ref() + .unwrap() + .attributes + .as_ref() + .unwrap() + .angle + .as_ref() + .unwrap() + .value_degrees, + 15.0 + ); + assert!(!library.graphics.iter().any(|graphic| matches!( + graphic, + konnect_ipc::IpcGraphicDefinition::Text { text, .. } + if text == "Example Assembly" + ))); + } + /// `preserved` must be a comparison of the rebuilt instance against the /// board's, never a policy constant: when the two instances genuinely /// diverge, the flags have to say so. @@ -1893,6 +2396,9 @@ mod tests { ); } + const KICAD_LIBRARY_FOOTPRINT: &str = + include_str!("../../tests/fixtures/socket_kicad10.kicad_mod"); + const LIBRARY_FOOTPRINT: &str = r#" (footprint "Socket" (version 20240108) @@ -2039,6 +2545,32 @@ mod tests { .collect() } + fn decoded_custom_fields( + instance: &kiapi::board::types::FootprintInstance, + ) -> Vec { + instance + .definition + .as_ref() + .unwrap() + .items + .iter() + .filter(|item| item.type_url.ends_with("kiapi.board.types.Field")) + .map(|item| { + kiapi::board::types::Field::decode(item.value.as_slice()) + .expect("custom field must decode") + }) + .collect() + } + + fn field_text_value(field: &kiapi::board::types::Field) -> &str { + field + .text + .as_ref() + .and_then(|text| text.text.as_ref()) + .map(|text| text.text.as_str()) + .unwrap_or_default() + } + #[test] fn parses_supported_library_definition_without_dropping_domains() { let library = parse_library_footprint("Test:Socket", LIBRARY_FOOTPRINT).unwrap(); @@ -2074,7 +2606,7 @@ mod tests { #[test] fn merge_preserves_instance_state_and_nets_by_logical_pad_number() { let current = current_instance(kiapi::board::types::BoardLayer::BlBCu); - let library = parse_library_footprint("Test:Socket", LIBRARY_FOOTPRINT).unwrap(); + let library = parse_library_footprint("Test:Socket", KICAD_LIBRARY_FOOTPRINT).unwrap(); let prepared = build_updated_instance( ¤t, &library, @@ -2136,6 +2668,42 @@ mod tests { current.symbol_footprint_filters ); + let properties = decoded_custom_fields(&updated); + assert_eq!( + properties + .iter() + .map(|property| (property.name.as_str(), field_text_value(property))) + .collect::>(), + vec![ + ("KiLib_Generator", "konnect_test_generator"), + ("AssemblyVendor", "Example Assembly"), + ] + ); + let assembly = properties + .iter() + .find(|property| property.name == "AssemblyVendor") + .unwrap(); + let assembly_text = assembly.text.as_ref().unwrap(); + assert_eq!( + assembly_text.layer, + kiapi::board::types::BoardLayer::BlBFab as i32 + ); + let text = assembly_text.text.as_ref().unwrap(); + let expected = konnect_sexp::geometry::transform_pad(0.5, -0.75, 100.0, 50.0, 37.0); + let actual = text.position.as_ref().unwrap(); + let actual = ( + builders::nm_to_mm(actual.x_nm), + builders::nm_to_mm(actual.y_nm), + ); + assert!((actual.0 - expected.0).abs() <= 0.000_001); + assert!((actual.1 - expected.1).abs() <= 0.000_001); + let attributes = text.attributes.as_ref().unwrap(); + assert!(attributes.mirrored); + assert_eq!( + attributes.angle.as_ref().unwrap().value_degrees, + readable_property_angle(180.0 - 15.0 + 37.0) + ); + let pads = decoded_pads(&updated); assert_eq!(pads.iter().filter(|pad| pad.number == "1").count(), 2); assert!(pads.iter().filter(|pad| pad.number == "1").all(|pad| pad @@ -2180,6 +2748,54 @@ mod tests { assert!(prepared.changed_domains.contains(&ChangedDomain::Models)); } + #[test] + fn merge_preserves_instance_only_properties_and_refreshes_library_properties() { + let mut current = current_instance(kiapi::board::types::BoardLayer::BlFCu); + let instance_only = field("InstanceNote", "keep this", 105.0, 55.0, false); + let stale_library_property = field("AssemblyVendor", "old library value", 99.0, 49.0, true); + current.definition.as_mut().unwrap().items.extend([ + builders::pack_any(&instance_only, "kiapi.board.types.Field"), + builders::pack_any(&stale_library_property, "kiapi.board.types.Field"), + ]); + let library = parse_library_footprint("Test:Socket", KICAD_LIBRARY_FOOTPRINT).unwrap(); + + let prepared = build_updated_instance( + ¤t, + &library, + &BTreeMap::from([("ROW1".to_string(), 11), ("COL1".to_string(), 12)]), + &BTreeSet::new(), + ) + .unwrap(); + let updated = + kiapi::board::types::FootprintInstance::decode(prepared.item.value.as_slice()).unwrap(); + let properties = decoded_custom_fields(&updated); + + assert_eq!( + properties + .iter() + .map(|property| property.name.as_str()) + .collect::>(), + vec!["InstanceNote", "KiLib_Generator", "AssemblyVendor"] + ); + assert_eq!( + properties + .iter() + .find(|property| property.name == "InstanceNote") + .unwrap(), + &instance_only + ); + assert_eq!( + field_text_value( + properties + .iter() + .find(|property| property.name == "AssemblyVendor") + .unwrap() + ), + "Example Assembly" + ); + assert!(prepared.changed_domains.contains(&ChangedDomain::Metadata)); + } + #[test] fn merge_conflicts_when_library_removes_a_connected_logical_pad() { let current = current_instance(kiapi::board::types::BoardLayer::BlFCu); @@ -2212,6 +2828,29 @@ mod tests { assert!(error.to_string().contains("zone"), "{error:#}"); } + #[test] + fn parser_names_unrepresentable_or_ambiguous_custom_properties() { + let unsupported = KICAD_LIBRARY_FOOTPRINT.replace( + "\t(property \"AssemblyVendor\" \"Example Assembly\"\n\t\t(at", + "\t(property \"AssemblyVendor\" \"Example Assembly\"\n\t\t(unlocked yes)\n\t\t(at", + ); + assert_ne!(unsupported, KICAD_LIBRARY_FOOTPRINT); + let error = parse_library_footprint("Test:Socket", &unsupported).unwrap_err(); + let message = error.to_string(); + assert!(message.contains("AssemblyVendor"), "{message}"); + assert!(message.contains("unlocked"), "{message}"); + + let duplicate = KICAD_LIBRARY_FOOTPRINT.replace( + "\"KiLib_Generator\" \"konnect_test_generator\"", + "\"AssemblyVendor\" \"konnect_test_generator\"", + ); + assert_ne!(duplicate, KICAD_LIBRARY_FOOTPRINT); + let error = parse_library_footprint("Test:Socket", &duplicate).unwrap_err(); + let message = error.to_string(); + assert!(message.contains("AssemblyVendor"), "{message}"); + assert!(message.contains("more than once"), "{message}"); + } + #[test] fn refresh_refuses_an_unrepresentable_layer_before_building_the_update() { let unsupported = LIBRARY_FOOTPRINT.replace("(layer \"F.SilkS\"))", "(layer \"In99.Cu\"))"); @@ -2298,7 +2937,7 @@ mod tests { #[test] fn rebuilding_an_applied_instance_is_a_noop_at_every_rotation_and_side() { - let library = parse_library_footprint("Test:Socket", LIBRARY_FOOTPRINT).unwrap(); + let library = parse_library_footprint("Test:Socket", KICAD_LIBRARY_FOOTPRINT).unwrap(); let net_codes = BTreeMap::from([("ROW1".to_string(), 11), ("COL1".to_string(), 12)]); let routed = BTreeSet::from(["ROW1".to_string(), "COL1".to_string()]); @@ -2469,7 +3108,11 @@ mod tests { std::fs::write(&board, "(kicad_pcb (version 20240108))").unwrap(); let library_dir = temp.path().join("Test.pretty"); std::fs::create_dir(&library_dir).unwrap(); - std::fs::write(library_dir.join("Socket.kicad_mod"), LIBRARY_FOOTPRINT).unwrap(); + std::fs::write( + library_dir.join("Socket.kicad_mod"), + KICAD_LIBRARY_FOOTPRINT, + ) + .unwrap(); std::fs::write( temp.path().join("fp-lib-table"), "(fp_lib_table (lib (name \"Test\") (type \"KiCad\") (uri \"${KIPRJMOD}/Test.pretty\") (options \"\") (descr \"\")))", @@ -2483,6 +3126,37 @@ mod tests { (temp, board, items) } + #[test] + fn planner_refuses_an_unrepresentable_property_before_preparing_any_update() { + let (temp, board, items) = plan_fixture(); + let unsupported = KICAD_LIBRARY_FOOTPRINT.replace( + "\t(property \"AssemblyVendor\" \"Example Assembly\"\n\t\t(at", + "\t(property \"AssemblyVendor\" \"Example Assembly\"\n\t\t(unlocked yes)\n\t\t(at", + ); + std::fs::write( + temp.path().join("Test.pretty/Socket.kicad_mod"), + unsupported, + ) + .unwrap(); + + let plan = plan_updates( + &board, + &items, + &BTreeMap::from([("ROW1".to_string(), 11), ("COL1".to_string(), 12)]), + &BTreeSet::new(), + &UpdateFilters::default(), + ); + + assert_eq!(plan.status, PlanStatus::Conflict); + assert!(plan.prepared_items.is_empty()); + assert!(plan.changes.is_empty()); + assert!(plan.diagnostics.iter().any(|diagnostic| { + diagnostic.code == "unsupported_library_footprint" + && diagnostic.message.contains("AssemblyVendor") + && diagnostic.message.contains("unlocked") + })); + } + #[test] fn filters_are_normalized_and_supplied_empty_arrays_select_nothing() { let filters = parse_filters(&serde_json::json!({ diff --git a/crates/konnect-core/tests/fixtures/socket_kicad10.kicad_mod b/crates/konnect-core/tests/fixtures/socket_kicad10.kicad_mod index 0c1b4154..09fbdcf5 100644 --- a/crates/konnect-core/tests/fixtures/socket_kicad10.kicad_mod +++ b/crates/konnect-core/tests/fixtures/socket_kicad10.kicad_mod @@ -49,6 +49,29 @@ ) ) ) + (property "KiLib_Generator" "konnect_test_generator" + (at 0 0 0) + (layer "F.SilkS") + (hide yes) + (uuid "d7d0fd3f-bcab-4eb1-8131-334305f93cd8") + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "AssemblyVendor" "Example Assembly" + (at 0.5 0.75 15) + (layer "F.Fab") + (hide yes) + (uuid "b15b8a94-8ee9-45f5-8720-af02ba40ef78") + (effects + (font + (size 1 1) + (thickness 0.15) + ) + ) + ) (attr smd exclude_from_pos_files) (duplicate_pad_numbers_are_jumpers no) (fp_line From 6b087d1debc66ea70095e529125d87cc6c6d5444 Mon Sep 17 00:00:00 2001 From: dubesinhower Date: Sat, 29 Aug 2026 20:33:52 -0400 Subject: [PATCH 05/16] fix(schematic): respect KiCad editor locks --- DEV.md | 5 + crates/konnect-core/src/mcp/handler.rs | 79 +++++++++++ crates/konnect-schematic-editor/src/error.rs | 3 + .../src/schematic/mod.rs | 3 + crates/konnect-sexp/src/error.rs | 8 ++ crates/konnect-sexp/src/transaction.rs | 67 ++++++++- crates/konnect-sexp/src/writer.rs | 128 +++++++++++++++++- docs/TROUBLESHOOTING.md | 14 ++ 8 files changed, 304 insertions(+), 3 deletions(-) diff --git a/DEV.md b/DEV.md index 270f761b..088991ae 100644 --- a/DEV.md +++ b/DEV.md @@ -192,6 +192,11 @@ Konnect/ - Cooperative lock files live under `KONNECT_STATE_DIR/locks` when that absolute override is set, otherwise under the platform local-data directory (`konnect/locks`). Reads never create files in the KiCad project. +- Schematic writes also refuse while KiCad's sibling `~.kicad_sch.lck` + exists. KiCad records only a username and hostname, so Konnect cannot prove + that a same-host or remote lock is stale; valid, foreign, empty, and malformed + locks all fail closed. The check runs before a transaction journal is created + and again at the final target-write boundary. - Multi-file schematic changes use project-local `.konnect-transaction-*.json` write-ahead journals. These journals contain complete before/after images and must be treated as sensitive project data. diff --git a/crates/konnect-core/src/mcp/handler.rs b/crates/konnect-core/src/mcp/handler.rs index 631506bd..c2d637b9 100644 --- a/crates/konnect-core/src/mcp/handler.rs +++ b/crates/konnect-core/src/mcp/handler.rs @@ -307,6 +307,24 @@ impl McpHandler { Some("invalid_argument".to_string()), ) } + Err(e) if kicad_editor_locked_path(&e).is_some() => { + let path = kicad_editor_locked_path(&e) + .expect("guard matched") + .display() + .to_string(); + ( + CallToolResult::error_kind( + ToolErrorKind::Conflict { + paths: vec![path.clone()], + }, + format!( + "Schematic '{path}' has a KiCad editor lock. Close Eeschema, or resolve a stale lock only after confirming no editor owns the file, then retry." + ), + ), + CallStatus::Error, + Some("conflict".to_string()), + ) + } Err(e) => { warn!(tool = %name, error = %e, "tool handler returned anyhow::Error"); let kind = ToolErrorKind::HandlerError { @@ -380,6 +398,22 @@ impl McpHandler { } } +fn kicad_editor_locked_path(error: &anyhow::Error) -> Option<&std::path::Path> { + for cause in error.chain() { + if let Some(konnect_sexp::SexpError::KiCadEditorLocked { path, .. }) = + cause.downcast_ref::() + { + return Some(path); + } + if let Some(konnect_schematic_editor::Error::KiCadEditorLocked { path, .. }) = + cause.downcast_ref::() + { + return Some(path); + } + } + None +} + /// Sum of content bytes in a `CallToolResult` — used for observability size /// accounting. Images are counted by their (already-base64-encoded) data len, /// which matches what the client sees over the wire. @@ -628,6 +662,51 @@ mod required_argument_dispatch_tests { "an empty uuids list is a request to delete nothing, not a mistake" ); } + + #[tokio::test] + async fn a_kicad_schematic_lock_is_a_typed_conflict() { + let handler = handler().await; + let directory = tempfile::tempdir().unwrap(); + let schematic = directory.path().join("locked.kicad_sch"); + let lock = directory.path().join("~locked.kicad_sch.lck"); + let source = "(kicad_sch\n\t(version 20250114)\n\t(generator \"eeschema\")\n\t\ + (uuid \"r\")\n\t(paper \"A4\")\n\t(lib_symbols)\n)\n"; + std::fs::write(&schematic, source).unwrap(); + std::fs::write( + &lock, + r#"{"username":"konnect-test","hostname":"test-host"}"#, + ) + .unwrap(); + + let (result, status, kind) = handler + .dispatch_tool( + "add_wire", + &json!({ + "schematic": schematic.display().to_string(), + "x1": 10.0, + "y1": 10.0, + "x2": 20.0, + "y2": 10.0 + }), + ) + .await; + + assert!(result.is_error); + assert_eq!(status, CallStatus::Error); + assert_eq!(kind.as_deref(), Some("conflict")); + let text = match result.content.first() { + Some(ToolContent::Text { text }) => text, + other => panic!("expected text, got {other:?}"), + }; + let body: Value = serde_json::from_str(text).unwrap(); + assert_eq!(body["error"]["kind"], "conflict"); + assert_eq!( + body["error"]["paths"], + json!([schematic.display().to_string()]) + ); + assert_eq!(std::fs::read_to_string(schematic).unwrap(), source); + assert!(lock.exists()); + } } /// Every registered tool refuses a call that omits its required arguments. diff --git a/crates/konnect-schematic-editor/src/error.rs b/crates/konnect-schematic-editor/src/error.rs index 1b853f5d..0a64fa08 100644 --- a/crates/konnect-schematic-editor/src/error.rs +++ b/crates/konnect-schematic-editor/src/error.rs @@ -14,6 +14,9 @@ pub enum Error { #[error("write conflict: '{0}' changed since it was loaded")] Conflict(PathBuf), + + #[error("KiCad editor lock blocks write to '{path}': {lock_path}")] + KiCadEditorLocked { path: PathBuf, lock_path: PathBuf }, } pub type Result = std::result::Result; diff --git a/crates/konnect-schematic-editor/src/schematic/mod.rs b/crates/konnect-schematic-editor/src/schematic/mod.rs index a2fb2730..4ef9fed1 100644 --- a/crates/konnect-schematic-editor/src/schematic/mod.rs +++ b/crates/konnect-schematic-editor/src/schematic/mod.rs @@ -623,6 +623,9 @@ fn map_sexp_error(error: konnect_sexp::SexpError) -> crate::error::Error { match error { konnect_sexp::SexpError::Io(error) => crate::error::Error::Io(error), konnect_sexp::SexpError::Conflict { path } => crate::error::Error::Conflict(path), + konnect_sexp::SexpError::KiCadEditorLocked { path, lock_path } => { + crate::error::Error::KiCadEditorLocked { path, lock_path } + } error => crate::error::Error::Io(std::io::Error::other(error)), } } diff --git a/crates/konnect-sexp/src/error.rs b/crates/konnect-sexp/src/error.rs index a17a41e3..9aca2692 100644 --- a/crates/konnect-sexp/src/error.rs +++ b/crates/konnect-sexp/src/error.rs @@ -22,6 +22,14 @@ pub enum SexpError { #[error("write conflict: {path} changed since it was read")] Conflict { path: PathBuf }, + /// KiCad owns, or may still own, the schematic through its sibling lock. + /// + /// KiCad lock files identify only a username and hostname, so their + /// presence cannot be distinguished reliably from a stale lock. Writers + /// must fail closed and leave both the document and the lock untouched. + #[error("KiCad editor lock blocks write to {path}: {lock_path}")] + KiCadEditorLocked { path: PathBuf, lock_path: PathBuf }, + /// A revision-aware command found that one of its target items no longer /// matches the exact item revision on which the command was prepared. #[error("item conflict in {path}: {item}: {reason}")] diff --git a/crates/konnect-sexp/src/transaction.rs b/crates/konnect-sexp/src/transaction.rs index de54ab2d..0cc3b34d 100644 --- a/crates/konnect-sexp/src/transaction.rs +++ b/crates/konnect-sexp/src/transaction.rs @@ -7,8 +7,8 @@ //! explicit resolution. use crate::writer::{ - open_document_lock, read_string_unlocked, sync_parent_directory, write_atomic_unlocked, - write_new_atomic_unlocked, + ensure_kicad_schematic_is_closed, open_document_lock, read_string_unlocked, + sync_parent_directory, write_atomic_unlocked, write_new_atomic_unlocked, }; use crate::SexpError; use fs4::FileExt; @@ -289,6 +289,7 @@ pub fn commit_file_transaction( }; let journal_path = journal_path(&root, &id); let _locks = lock_entries(&root, &journal.entries)?; + ensure_entries_are_closed(&root, &journal.entries)?; verify_before_images(&root, &journal_path, &journal.entries)?; persist_journal(&journal_path, &journal)?; @@ -439,6 +440,7 @@ pub fn abandon_file_transaction( fn recover_journal(root: &Path, journal_path: &Path) -> Result { let journal = read_validated_journal(root, journal_path)?; let _locks = lock_entries(root, &journal.entries)?; + ensure_entries_are_closed(root, &journal.entries)?; let mut pending = Vec::new(); for entry in &journal.entries { let path = root.join(&entry.path); @@ -648,6 +650,13 @@ fn lock_entries(root: &Path, entries: &[JournalEntry]) -> Result Result<(), SexpError> { + for entry in entries { + ensure_kicad_schematic_is_closed(&root.join(&entry.path))?; + } + Ok(()) +} + fn verify_before_images( root: &Path, journal: &Path, @@ -871,6 +880,31 @@ mod tests { .is_empty()); } + #[test] + fn editor_lock_changes_nothing_and_leaves_no_journal() { + let directory = tempfile::tempdir().expect("temporary directory"); + let parent = directory.path().join("root.kicad_sch"); + let child = directory.path().join("child.kicad_sch"); + let lock = directory.path().join("~root.kicad_sch.lck"); + std::fs::write(&parent, "parent before").expect("write parent"); + std::fs::write(&lock, "not parseable").expect("write editor lock"); + + let error = commit_file_transaction( + directory.path(), + vec![ + FileTransition::replace(&parent, "parent before", "parent after"), + FileTransition::create(&child, "child after"), + ], + ) + .expect_err("editor lock conflicts"); + + assert!(matches!(error, SexpError::KiCadEditorLocked { .. })); + assert_eq!(std::fs::read_to_string(parent).unwrap(), "parent before"); + assert!(!child.exists()); + assert!(active_journal_paths(directory.path()).unwrap().is_empty()); + assert!(lock.exists()); + } + #[test] fn recovery_finishes_a_partially_applied_transaction() { let directory = tempfile::tempdir().expect("temporary directory"); @@ -905,6 +939,35 @@ mod tests { assert!(!journal_path.exists()); } + #[test] + fn recovery_defers_to_an_editor_lock_without_changing_the_journal() { + let directory = tempfile::tempdir().expect("temporary directory"); + let root = directory.path().canonicalize().unwrap(); + let parent = root.join("root.kicad_sch"); + let lock = root.join("~root.kicad_sch.lck"); + std::fs::write(&parent, "parent before").expect("write parent"); + std::fs::write(&lock, "stale-looking lock").expect("write editor lock"); + let journal = Journal { + version: JOURNAL_VERSION, + id: "locked-recovery".to_owned(), + entries: vec![JournalEntry { + path: PathBuf::from("root.kicad_sch"), + expected: Some("parent before".to_owned()), + replacement: "parent after".to_owned(), + }], + }; + let journal_path = journal_path(&root, &journal.id); + persist_journal(&journal_path, &journal).expect("persist crash journal"); + let journal_before = std::fs::read(&journal_path).expect("read journal"); + + let error = recover_file_transactions(&root).expect_err("editor lock conflicts"); + + assert!(matches!(error, SexpError::KiCadEditorLocked { .. })); + assert_eq!(std::fs::read_to_string(parent).unwrap(), "parent before"); + assert_eq!(std::fs::read(&journal_path).unwrap(), journal_before); + assert!(lock.exists()); + } + #[test] fn recovery_preserves_divergent_content_and_journal() { let directory = tempfile::tempdir().expect("temporary directory"); diff --git a/crates/konnect-sexp/src/writer.rs b/crates/konnect-sexp/src/writer.rs index 6ac6687d..b226a680 100644 --- a/crates/konnect-sexp/src/writer.rs +++ b/crates/konnect-sexp/src/writer.rs @@ -22,7 +22,7 @@ use crate::SexpError; use fs4::FileExt; use sha2::{Digest, Sha256}; -use std::ffi::OsStr; +use std::ffi::{OsStr, OsString}; use std::fs::OpenOptions; use std::io::{Read, Write}; use std::path::{Path, PathBuf}; @@ -110,6 +110,7 @@ pub fn write_atomic(path: &Path, content: &str) -> Result<(), SexpError> { } pub(crate) fn write_atomic_unlocked(path: &Path, content: &str) -> Result<(), SexpError> { + ensure_kicad_schematic_is_closed(path)?; let (tmp_path, mut file) = create_scratch_file(path)?; // Remove the scratch file unless the rename below succeeds. @@ -124,6 +125,9 @@ pub(crate) fn write_atomic_unlocked(path: &Path, content: &str) -> Result<(), Se file.sync_all()?; // fsync — mandatory drop(file); + // A lock may have appeared while the scratch file was being written. + // Recheck at the last refusal point before replacing the document. + ensure_kicad_schematic_is_closed(path)?; std::fs::rename(&tmp_path, path)?; cleanup.disarm(); sync_parent_directory(path.parent().unwrap_or_else(|| Path::new(".")))?; @@ -201,6 +205,41 @@ pub(crate) fn open_document_lock(path: &Path) -> Result Result<(), SexpError> { + let Some(lock_path) = kicad_schematic_lock_path(path) else { + return Ok(()); + }; + + match std::fs::symlink_metadata(&lock_path) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Ok(_) | Err(_) => Err(SexpError::KiCadEditorLocked { + path: path.to_path_buf(), + lock_path, + }), + } +} + +fn kicad_schematic_lock_path(path: &Path) -> Option { + let is_schematic = path + .extension() + .and_then(OsStr::to_str) + .is_some_and(|extension| extension.eq_ignore_ascii_case("kicad_sch")); + if !is_schematic { + return None; + } + let mut name = OsString::from("~"); + name.push(path.file_name()?); + name.push(".lck"); + Some(path.with_file_name(name)) +} + fn open_lock_file(lock_path: &Path) -> Result { reject_non_file_lock_path(lock_path)?; let lock = OpenOptions::new() @@ -320,6 +359,7 @@ pub fn write_new_atomic(path: &Path, content: &str) -> Result<(), SexpError> { } pub(crate) fn write_new_atomic_unlocked(path: &Path, content: &str) -> Result<(), SexpError> { + ensure_kicad_schematic_is_closed(path)?; let parent = path.parent().unwrap_or_else(|| Path::new(".")); let mut temporary = tempfile::Builder::new() .prefix(".konnect-") @@ -327,6 +367,7 @@ pub(crate) fn write_new_atomic_unlocked(path: &Path, content: &str) -> Result<() temporary.write_all(content.as_bytes())?; temporary.flush()?; temporary.as_file().sync_all()?; + ensure_kicad_schematic_is_closed(path)?; temporary .persist_noclobber(path) .map_err(|error| SexpError::Io(error.error))?; @@ -1199,6 +1240,91 @@ mod atomic_write_tests { assert_eq!(std::fs::read_to_string(path).unwrap(), "user project"); } + #[test] + fn conditional_write_rejects_a_kicad_schematic_lock() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("design.kicad_sch"); + let lock = directory.path().join("~design.kicad_sch.lck"); + std::fs::write(&path, "expected").unwrap(); + std::fs::write( + &lock, + r#"{"username":"konnect-test","hostname":"test-host"}"#, + ) + .unwrap(); + + assert_eq!(read_consistent(&path).unwrap(), "expected"); + + let error = write_atomic_if_unchanged(&path, "expected", "edited").unwrap_err(); + + assert_eq!(std::fs::read_to_string(path).unwrap(), "expected"); + assert!(lock.exists()); + assert!(matches!( + error, + SexpError::KiCadEditorLocked { + path: blocked_path, + lock_path + } if blocked_path.ends_with("design.kicad_sch") && lock_path == lock + )); + } + + #[test] + fn stale_looking_kicad_schematic_lock_still_blocks() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("design.kicad_sch"); + let lock = directory.path().join("~design.kicad_sch.lck"); + std::fs::write(&path, "expected").unwrap(); + std::fs::write( + &lock, + r#"{"username":"former-user","hostname":"retired-host"}"#, + ) + .unwrap(); + + let error = write_atomic_if_unchanged(&path, "expected", "edited").unwrap_err(); + + assert!(matches!(error, SexpError::KiCadEditorLocked { .. })); + assert_eq!(std::fs::read_to_string(path).unwrap(), "expected"); + } + + #[test] + fn malformed_kicad_schematic_lock_still_blocks() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("design.kicad_sch"); + let lock = directory.path().join("~design.kicad_sch.lck"); + std::fs::write(&path, "expected").unwrap(); + std::fs::write(lock, "not JSON").unwrap(); + + let error = write_atomic_if_unchanged(&path, "expected", "edited").unwrap_err(); + + assert!(matches!(error, SexpError::KiCadEditorLocked { .. })); + assert_eq!(std::fs::read_to_string(path).unwrap(), "expected"); + } + + #[test] + fn kicad_lock_name_does_not_block_a_non_schematic_write() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("design.kicad_pcb"); + let lock = directory.path().join("~design.kicad_pcb.lck"); + std::fs::write(&path, "expected").unwrap(); + std::fs::write(lock, "not relevant to this shared writer").unwrap(); + + write_atomic_if_unchanged(&path, "expected", "edited").unwrap(); + + assert_eq!(std::fs::read_to_string(path).unwrap(), "edited"); + } + + #[test] + fn atomic_schematic_create_rejects_a_preexisting_kicad_lock() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("new.kicad_sch"); + let lock = directory.path().join("~new.kicad_sch.lck"); + std::fs::write(lock, "").unwrap(); + + let error = write_new_atomic(&path, "new schematic").unwrap_err(); + + assert!(matches!(error, SexpError::KiCadEditorLocked { .. })); + assert!(!path.exists()); + } + #[test] fn conditional_write_rejects_a_stale_revision() { let directory = tempfile::tempdir().unwrap(); diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 7c676d4f..5b0fdb06 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -155,6 +155,20 @@ This option is unavailable on KiCad 11 after removal of the legacy SWIG Python API. Konnect then uses its Rust exporter unless KiCad gains an equivalent supported IPC operation. +## A schematic write is blocked by a KiCad editor lock + +Konnect refuses to change a `.kicad_sch` file while the sibling +`~.kicad_sch.lck` exists. Close the schematic editor normally and retry. +Read-only schematic tools remain available while the lock exists. + +KiCad's lock stores only a username and hostname, not a process identifier or +document-instance token. Konnect therefore cannot distinguish a live lock from +one left by a crash without risking unsaved editor state. It treats valid, +foreign-host, empty, and malformed locks alike and never removes one +automatically. If KiCad crashed, first confirm that no schematic editor owns the +file; reopening and closing the project cleanly is the preferred way to resolve +the lock. Remove a confirmed stale lock manually only as a last resort. + ## Transaction recovery is blocked by divergent content Multi-file schematic changes persist a `.konnect-transaction-.json` From f8477c981f4a006a9fda713de72804ec98d5f15f Mon Sep 17 00:00:00 2001 From: dubesinhower Date: Sat, 29 Aug 2026 21:15:43 -0400 Subject: [PATCH 06/16] feat(review): add hierarchy scope to standalone audits --- .../konnect-core/src/tools/design_review.rs | 617 ++++++++++++++++-- .../assets/skills/kicad-review/SKILL.md | 14 +- .../references/design-checklist.md | 7 +- crates/konnect/tests/asset_references.rs | 2 + tool-directory.md | 8 +- 5 files changed, 584 insertions(+), 64 deletions(-) diff --git a/crates/konnect-core/src/tools/design_review.rs b/crates/konnect-core/src/tools/design_review.rs index 2e230d84..9b4aafe8 100644 --- a/crates/konnect-core/src/tools/design_review.rs +++ b/crates/konnect-core/src/tools/design_review.rs @@ -10,7 +10,8 @@ use crate::mcp::protocol::CallToolResult; use crate::tool; use crate::tools::sch_connectivity::{net_graph_for, NetGraph}; use crate::tools::{ - get_path, placed_pins_by_reference, project_name_for, sch_hierarchy, ToolContext, ToolDef, + get_path, invalid_arg, placed_pins_by_reference, project_name_for, sch_hierarchy, ToolContext, + ToolDef, }; use konnect_schematic_editor as cse; use konnect_sexp::{ @@ -34,11 +35,18 @@ pub fn tools() -> Vec { "audit_decoupling", "Audit schematic connectivity between IC power nets and decoupling capacitors. \ This does not inspect PCB placement distance; use PCB clearance/placement tools \ - for a physical review.", + for a physical review. Defaults to one file; set schematic_scope to 'hierarchy' \ + to audit every reachable sheet instance.", json!({ "type": "object", "properties": { - "schematic": { "type": "string", "description": "Path to .kicad_sch file" } + "schematic": { "type": "string", "description": "Path to .kicad_sch file" }, + "schematic_scope": { + "type": "string", + "enum": ["file", "hierarchy"], + "description": "Audit only the supplied file (default) or every reachable hierarchy instance", + "default": "file" + } }, "required": ["schematic"] }), @@ -47,11 +55,19 @@ pub fn tools() -> Vec { tool!( "audit_connections", "Check for common connection mistakes: missing pull-ups on I2C/reset, \ - missing series resistors on LEDs, floating inputs, outputs shorted together.", + missing series resistors on LEDs, floating inputs, outputs shorted together. \ + Defaults to one file; set schematic_scope to 'hierarchy' to audit every \ + reachable sheet instance.", json!({ "type": "object", "properties": { - "schematic": { "type": "string", "description": "Path to .kicad_sch file" } + "schematic": { "type": "string", "description": "Path to .kicad_sch file" }, + "schematic_scope": { + "type": "string", + "enum": ["file", "hierarchy"], + "description": "Audit only the supplied file (default) or every reachable hierarchy instance", + "default": "file" + } }, "required": ["schematic"] }), @@ -60,11 +76,18 @@ pub fn tools() -> Vec { tool!( "audit_power_rails", "Check power rail integrity: missing bulk capacitance, no test points on power rails, \ - voltage regulator output caps missing.", + voltage regulator output caps missing. Defaults to one file; set schematic_scope \ + to 'hierarchy' to audit every reachable sheet instance.", json!({ "type": "object", "properties": { - "schematic": { "type": "string", "description": "Path to .kicad_sch file" } + "schematic": { "type": "string", "description": "Path to .kicad_sch file" }, + "schematic_scope": { + "type": "string", + "enum": ["file", "hierarchy"], + "description": "Audit only the supplied file (default) or every reachable hierarchy instance", + "default": "file" + } }, "required": ["schematic"] }), @@ -115,11 +138,18 @@ pub fn tools() -> Vec { tool!( "check_bom_health", "Analyze the BOM for supply chain risks: parts with no MPN, lifecycle warnings, \ - low stock, parts not available from preferred distributors.", + low stock, parts not available from preferred distributors. Defaults to one \ + file; set schematic_scope to 'hierarchy' to audit every reachable sheet instance.", json!({ "type": "object", "properties": { - "schematic": { "type": "string", "description": "Path to .kicad_sch file" } + "schematic": { "type": "string", "description": "Path to .kicad_sch file" }, + "schematic_scope": { + "type": "string", + "enum": ["file", "hierarchy"], + "description": "Audit only the supplied file (default) or every reachable hierarchy instance", + "default": "file" + } }, "required": ["schematic"] }), @@ -139,9 +169,65 @@ struct AuditFinding { recommendation: String, } -// ─── Decoupling audit ──────────────────────────────────────────────────────── +#[derive(Clone, Copy)] +enum StandaloneSchematicAudit { + Decoupling, + Connections, + PowerRails, + BomHealth, +} + +impl StandaloneSchematicAudit { + fn name(self) -> &'static str { + match self { + Self::Decoupling => "decoupling", + Self::Connections => "connections", + Self::PowerRails => "power_rails", + Self::BomHealth => "bom_health", + } + } + + async fn run_file(self, args: &Value, ctx: &ToolContext) -> anyhow::Result { + match self { + Self::Decoupling => handle_audit_decoupling_file(args, ctx).await, + Self::Connections => handle_audit_connections_file(args, ctx).await, + Self::PowerRails => handle_audit_power_rails_file(args, ctx).await, + Self::BomHealth => handle_check_bom_health_file(args, ctx).await, + } + } +} async fn handle_audit_decoupling( + args: &Value, + ctx: &ToolContext, +) -> anyhow::Result { + handle_scoped_schematic_audit(StandaloneSchematicAudit::Decoupling, args, ctx).await +} + +async fn handle_audit_connections( + args: &Value, + ctx: &ToolContext, +) -> anyhow::Result { + handle_scoped_schematic_audit(StandaloneSchematicAudit::Connections, args, ctx).await +} + +async fn handle_audit_power_rails( + args: &Value, + ctx: &ToolContext, +) -> anyhow::Result { + handle_scoped_schematic_audit(StandaloneSchematicAudit::PowerRails, args, ctx).await +} + +async fn handle_check_bom_health( + args: &Value, + ctx: &ToolContext, +) -> anyhow::Result { + handle_scoped_schematic_audit(StandaloneSchematicAudit::BomHealth, args, ctx).await +} + +// ─── Decoupling audit ──────────────────────────────────────────────────────── + +async fn handle_audit_decoupling_file( args: &serde_json::Value, _ctx: &ToolContext, ) -> anyhow::Result { @@ -239,7 +325,7 @@ async fn handle_audit_decoupling( // ─── Connection audit ──────────────────────────────────────────────────────── -async fn handle_audit_connections( +async fn handle_audit_connections_file( args: &serde_json::Value, _ctx: &ToolContext, ) -> anyhow::Result { @@ -329,7 +415,7 @@ async fn handle_audit_connections( // ─── Power rail audit ──────────────────────────────────────────────────────── -async fn handle_audit_power_rails( +async fn handle_audit_power_rails_file( args: &serde_json::Value, _ctx: &ToolContext, ) -> anyhow::Result { @@ -611,51 +697,93 @@ impl AuditAggregate { } } -fn collect_hierarchy_paths( +#[derive(Clone)] +struct SchematicSheetInstance { + path: PathBuf, + instance_path: String, +} + +#[derive(Default)] +struct SchematicHierarchyTraversal { + sheet_instances: usize, + schematic_files: Vec, + auditable_instances: Vec, + diagnostics: Vec, + seen_files: HashSet, +} + +fn collect_hierarchy( path: &Path, + instance_path: &str, node: &Value, - sheet_instances: &mut usize, - seen_files: &mut HashSet, - files: &mut Vec, - diagnostics: &mut Vec, + traversal: &mut SchematicHierarchyTraversal, ) { - *sheet_instances += 1; + traversal.sheet_instances += 1; let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); - if seen_files.insert(canonical) { - files.push(path.to_path_buf()); + if traversal.seen_files.insert(canonical) { + traversal.schematic_files.push(path.to_path_buf()); } - if let Some(error) = node.get("error").and_then(Value::as_str) { - diagnostics.push(json!({ + let hierarchy_error = node.get("error").and_then(Value::as_str); + if let Some(error) = hierarchy_error { + traversal.diagnostics.push(json!({ "code": "hierarchy_error", "source": path.display().to_string(), + "sheet_instance_path": instance_path, "message": error })); + } else { + traversal.auditable_instances.push(SchematicSheetInstance { + path: path.to_path_buf(), + instance_path: instance_path.to_string(), + }); } let parent = path.parent().unwrap_or_else(|| Path::new(".")); if let Some(children) = node.get("children").and_then(Value::as_array) { - for child in children { + for (index, child) in children.iter().enumerate() { let Some(file) = child.get("file").and_then(Value::as_str) else { - diagnostics.push(json!({ + traversal.diagnostics.push(json!({ "code": "hierarchy_error", "source": path.display().to_string(), + "sheet_instance_path": instance_path, "message": "hierarchy entry has no child file" })); continue; }; - collect_hierarchy_paths( + let child_uuid = child + .get("uuid") + .and_then(Value::as_str) + .map(str::to_string) + .unwrap_or_else(|| { + traversal.diagnostics.push(json!({ + "code": "hierarchy_error", + "source": path.display().to_string(), + "sheet_instance_path": instance_path, + "message": "hierarchy entry has no sheet UUID" + })); + format!("unknown-{index}") + }); + collect_hierarchy( &parent.join(file), + &format!("{instance_path}{child_uuid}/"), child, - sheet_instances, - seen_files, - files, - diagnostics, + traversal, ); } } } +fn inspect_hierarchy(root_path: &Path) -> anyhow::Result { + let project_name = project_name_for(root_path); + let mut hierarchy_visited = HashSet::new(); + let hierarchy = + sch_hierarchy::build_hierarchy_node(root_path, &project_name, 0, &mut hierarchy_visited)?; + let mut traversal = SchematicHierarchyTraversal::default(); + collect_hierarchy(root_path, "/", &hierarchy, &mut traversal); + Ok(traversal) +} + fn inspect_schematic_coverage( path: &Path, coverage: &mut SchematicReviewCoverage, @@ -731,6 +859,205 @@ fn args_for_schematic(args: &Value, path: &Path) -> Value { sheet_args } +fn requested_schematic_scope(args: &Value) -> Result<&'static str, CallToolResult> { + match args.get("schematic_scope") { + None | Some(Value::Null) => Ok("file"), + Some(Value::String(scope)) if scope == "file" => Ok("file"), + Some(Value::String(scope)) if scope == "hierarchy" => Ok("hierarchy"), + Some(Value::String(_)) => Err(invalid_arg( + "schematic_scope", + "expected 'file' or 'hierarchy'", + )), + Some(_) => Err(invalid_arg("schematic_scope", "expected a string")), + } +} + +fn audit_result_json(result: CallToolResult) -> anyhow::Result { + let text = match result.content.first() { + Some(crate::mcp::protocol::ToolContent::Text { text }) => text, + Some(_) => anyhow::bail!("audit returned non-text content"), + None => anyhow::bail!("audit returned no content"), + }; + if result.is_error { + anyhow::bail!("audit returned an error result: {text}"); + } + let body: Value = serde_json::from_str(text)?; + if !body.is_object() { + anyhow::bail!("audit result was not a JSON object"); + } + Ok(body) +} + +fn schematic_symbol_count(path: &Path) -> anyhow::Result { + let (_, tree) = read_schematic(path)?; + Ok(extract_symbol_instances(&tree).len()) +} + +fn decorate_file_audit_result(body: &mut Value, symbol_instances: usize) { + body["schematic_scope"] = json!("file"); + body["status"] = json!("complete"); + body["coverage"] = json!({ + "sheet_instances": 1, + "audited_sheet_instances": 1, + "schematic_files": 1, + "symbol_instances": symbol_instances + }); + body["diagnostics"] = json!([]); +} + +fn sum_sheet_metric(sheet_results: &[Value], key: &str) -> u64 { + sheet_results + .iter() + .filter_map(|result| result.get(key).and_then(Value::as_u64)) + .sum() +} + +async fn handle_scoped_schematic_audit( + audit: StandaloneSchematicAudit, + args: &Value, + ctx: &ToolContext, +) -> anyhow::Result { + let schematic_scope = match requested_schematic_scope(args) { + Ok(scope) => scope, + Err(error) => return Ok(error), + }; + let root_path = get_path(args, "schematic")?; + + if schematic_scope == "file" { + let result = audit.run_file(args, ctx).await?; + let mut body = audit_result_json(result)?; + decorate_file_audit_result(&mut body, schematic_symbol_count(&root_path)?); + return Ok(CallToolResult::text(serde_json::to_string(&body)?)); + } + + let traversal = inspect_hierarchy(&root_path)?; + let sheet_instances = traversal.sheet_instances; + let schematic_files = traversal.schematic_files.len(); + let mut diagnostics = traversal.diagnostics; + let mut sheet_results = Vec::new(); + let mut findings = Vec::new(); + let mut symbol_instances = 0usize; + + for sheet in &traversal.auditable_instances { + let sheet_args = args_for_schematic(args, &sheet.path); + let result = match audit.run_file(&sheet_args, ctx).await { + Ok(result) => result, + Err(error) => { + diagnostics.push(json!({ + "code": "audit_failed", + "audit": audit.name(), + "source": sheet.path.display().to_string(), + "sheet_instance_path": sheet.instance_path, + "message": error.to_string() + })); + continue; + } + }; + let mut body = match audit_result_json(result) { + Ok(body) => body, + Err(error) => { + diagnostics.push(json!({ + "code": "invalid_audit_result", + "audit": audit.name(), + "source": sheet.path.display().to_string(), + "sheet_instance_path": sheet.instance_path, + "message": error.to_string() + })); + continue; + } + }; + let sheet_symbol_instances = match schematic_symbol_count(&sheet.path) { + Ok(count) => count, + Err(error) => { + diagnostics.push(json!({ + "code": "schematic_parse_failed", + "source": sheet.path.display().to_string(), + "sheet_instance_path": sheet.instance_path, + "message": error.to_string() + })); + continue; + } + }; + symbol_instances += sheet_symbol_instances; + decorate_file_audit_result(&mut body, sheet_symbol_instances); + body["source"] = json!(sheet.path.display().to_string()); + body["sheet_instance_path"] = json!(sheet.instance_path); + if let Some(sheet_findings) = body.get_mut("findings").and_then(Value::as_array_mut) { + for finding in sheet_findings { + finding["source"] = json!(sheet.path.display().to_string()); + finding["sheet_instance_path"] = json!(sheet.instance_path); + findings.push(finding.clone()); + } + } + sheet_results.push(body); + } + + let status = if sheet_results.is_empty() { + "failed" + } else if diagnostics.is_empty() { + "complete" + } else { + "partial" + }; + let audited_sheet_instances = sheet_results.len(); + let finding_count = findings.len(); + let mut body = json!({ + "audit": audit.name(), + "schematic_scope": "hierarchy", + "status": status, + "coverage": { + "sheet_instances": sheet_instances, + "audited_sheet_instances": audited_sheet_instances, + "schematic_files": schematic_files, + "symbol_instances": symbol_instances + }, + "findings": findings, + "sheet_results": sheet_results.clone(), + "diagnostics": diagnostics, + "summary": format!( + "{} audit covered {}/{} sheet instances across {} schematic files; {} findings.", + audit.name(), + audited_sheet_instances, + sheet_instances, + schematic_files, + finding_count + ) + }); + + match audit { + StandaloneSchematicAudit::Decoupling => { + // Preserve the pre-existing field that distinguishes connectivity + // review from a physical PCB-distance check. `schematic_scope` is + // the new file-versus-hierarchy contract. + body["scope"] = json!("schematic_connectivity"); + body["pcb_distance_checked"] = json!(false); + body["pass_count"] = json!(sum_sheet_metric(&sheet_results, "pass_count")); + body["total_power_pins"] = json!(sum_sheet_metric(&sheet_results, "total_power_pins")); + } + StandaloneSchematicAudit::Connections => {} + StandaloneSchematicAudit::PowerRails => { + body["power_nets"] = json!(sheet_results + .iter() + .filter_map(|result| result.get("power_nets").and_then(Value::as_array)) + .flatten() + .cloned() + .collect::>()); + } + StandaloneSchematicAudit::BomHealth => { + for key in [ + "total_components", + "missing_mpn", + "missing_footprint", + "missing_value", + ] { + body[key] = json!(sum_sheet_metric(&sheet_results, key)); + } + } + } + + Ok(CallToolResult::text(serde_json::to_string(&body)?)) +} + async fn handle_run_design_review( args: &serde_json::Value, ctx: &ToolContext, @@ -744,24 +1071,19 @@ async fn handle_run_design_review( }; let root_path = get_path(args, "schematic")?; - let project_name = project_name_for(&root_path); - let mut hierarchy_visited = HashSet::new(); - let hierarchy = - sch_hierarchy::build_hierarchy_node(&root_path, &project_name, 0, &mut hierarchy_visited)?; - - let mut diagnostics = Vec::new(); - let mut schematic_coverage = SchematicReviewCoverage::default(); - let mut schematic_files = Vec::new(); - let mut seen_files = HashSet::new(); - collect_hierarchy_paths( - &root_path, - &hierarchy, - &mut schematic_coverage.sheet_instances, - &mut seen_files, - &mut schematic_files, - &mut diagnostics, - ); - schematic_coverage.schematic_files = schematic_files.len(); + let traversal = inspect_hierarchy(&root_path)?; + let mut diagnostics = traversal.diagnostics; + let mut schematic_coverage = SchematicReviewCoverage { + sheet_instances: traversal.sheet_instances, + schematic_files: traversal.schematic_files.len(), + ..SchematicReviewCoverage::default() + }; + for sheet in &traversal.auditable_instances { + // A reused child file represents a distinct KiCad sheet instance each + // time it appears. Count its symbols and named nets once per instance, + // while the file-level audit calls below remain deduplicated. + inspect_schematic_coverage(&sheet.path, &mut schematic_coverage, &mut diagnostics); + } let mut audits = vec![ AuditAggregate::new("decoupling"), @@ -770,17 +1092,16 @@ async fn handle_run_design_review( AuditAggregate::new("bom_health"), ]; - for schematic_path in &schematic_files { - inspect_schematic_coverage(schematic_path, &mut schematic_coverage, &mut diagnostics); + for schematic_path in &traversal.schematic_files { let sheet_args = args_for_schematic(args, schematic_path); - let result = handle_audit_decoupling(&sheet_args, ctx).await; + let result = handle_audit_decoupling_file(&sheet_args, ctx).await; audits[0].record(schematic_path, result, &mut diagnostics); - let result = handle_audit_connections(&sheet_args, ctx).await; + let result = handle_audit_connections_file(&sheet_args, ctx).await; audits[1].record(schematic_path, result, &mut diagnostics); - let result = handle_audit_power_rails(&sheet_args, ctx).await; + let result = handle_audit_power_rails_file(&sheet_args, ctx).await; audits[2].record(schematic_path, result, &mut diagnostics); - let result = handle_check_bom_health(&sheet_args, ctx).await; + let result = handle_check_bom_health_file(&sheet_args, ctx).await; audits[3].record(schematic_path, result, &mut diagnostics); } @@ -995,7 +1316,7 @@ async fn handle_run_design_review( // ─── BOM health check ─────────────────────────────────────────────────────── -async fn handle_check_bom_health( +async fn handle_check_bom_health_file( args: &serde_json::Value, _ctx: &ToolContext, ) -> anyhow::Result { @@ -1465,6 +1786,32 @@ mod review_completion_tests { root } + fn root_with_reused_child(file: &str) -> String { + let mut root = root_with_child(file); + let insert_at = root.rfind(')').expect("blank schematic has a root close"); + let block = format_hierarchical_sheet(HierarchicalSheetSpec { + name: "Power B", + file, + x: 120.0, + y: 20.0, + width: 80.0, + height: 50.0, + project_name: "root", + parent_instance_path: "/11111111-1111-4111-8111-111111111111", + page: "3", + }); + root.insert_str(insert_at, &block); + root + } + + fn tool_json(result: CallToolResult) -> Value { + assert!(!result.is_error, "audit must return a structured result"); + let crate::mcp::protocol::ToolContent::Text { text } = &result.content[0] else { + panic!("audit result must be text JSON") + }; + serde_json::from_str(text).expect("audit result must be valid JSON") + } + fn review_json(result: CallToolResult) -> Value { assert!( !result.is_error, @@ -1544,6 +1891,168 @@ mod review_completion_tests { .any(|finding| finding["source"] == child.display().to_string())); } + #[test] + fn every_standalone_schematic_audit_declares_hierarchy_scope() { + for name in [ + "audit_decoupling", + "audit_connections", + "audit_power_rails", + "check_bom_health", + ] { + let tool = tools() + .into_iter() + .find(|tool| tool.name == name) + .expect("standalone audit must exist"); + assert_eq!( + tool.input_schema["properties"]["schematic_scope"]["enum"], + json!(["file", "hierarchy"]), + "{name} must make its schematic scope explicit" + ); + assert_eq!( + tool.input_schema["properties"]["schematic_scope"]["default"], "file", + "{name} must preserve the existing one-file default" + ); + } + } + + #[tokio::test] + async fn hierarchy_scope_counts_reused_child_instances_for_every_audit() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path().join("root.kicad_sch"); + let child = tmp.path().join("shared.kicad_sch"); + std::fs::write(&root, root_with_reused_child("shared.kicad_sch")).unwrap(); + std::fs::write(&child, single_unit_schematic("")).unwrap(); + let args = json!({ + "schematic": root.display().to_string(), + "schematic_scope": "hierarchy" + }); + + let results = [ + handle_audit_decoupling(&args, &test_ctx()).await.unwrap(), + handle_audit_connections(&args, &test_ctx()).await.unwrap(), + handle_audit_power_rails(&args, &test_ctx()).await.unwrap(), + handle_check_bom_health(&args, &test_ctx()).await.unwrap(), + ]; + + for result in results { + let audit = tool_json(result); + assert_eq!(audit["schematic_scope"], "hierarchy", "{audit}"); + assert_eq!(audit["status"], "complete", "{audit}"); + assert_eq!(audit["coverage"]["sheet_instances"], 3, "{audit}"); + assert_eq!(audit["coverage"]["schematic_files"], 2, "{audit}"); + assert_eq!(audit["coverage"]["symbol_instances"], 2, "{audit}"); + assert_eq!(audit["sheet_results"].as_array().unwrap().len(), 3); + assert!(audit["diagnostics"].as_array().unwrap().is_empty()); + } + } + + #[tokio::test] + async fn design_review_uses_shared_instance_aware_hierarchy_coverage() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path().join("root.kicad_sch"); + let child = tmp.path().join("shared.kicad_sch"); + std::fs::write(&root, root_with_reused_child("shared.kicad_sch")).unwrap(); + std::fs::write(&child, single_unit_schematic("")).unwrap(); + + let result = review(&root, None).await; + let report = &result["design_review"]; + assert_eq!(report["coverage"]["schematic"]["sheet_instances"], 3); + assert_eq!(report["coverage"]["schematic"]["schematic_files"], 2); + assert_eq!(report["coverage"]["schematic"]["symbol_instances"], 2); + assert_eq!( + report["audits"]["bom_health"]["requested"], 2, + "file-level review work remains deduplicated even though coverage is instance-aware" + ); + } + + #[tokio::test] + async fn standalone_file_scope_remains_the_default() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path().join("root.kicad_sch"); + let child = tmp.path().join("child.kicad_sch"); + std::fs::write(&root, root_with_child("child.kicad_sch")).unwrap(); + std::fs::write(&child, single_unit_schematic("")).unwrap(); + + let result = tool_json( + handle_check_bom_health( + &json!({"schematic": root.display().to_string()}), + &test_ctx(), + ) + .await + .unwrap(), + ); + assert_eq!(result["schematic_scope"], "file"); + assert_eq!(result["status"], "complete"); + assert_eq!(result["total_components"], 0); + assert_eq!(result["coverage"]["sheet_instances"], 1); + assert_eq!(result["coverage"]["schematic_files"], 1); + assert_eq!(result["coverage"]["symbol_instances"], 0); + } + + #[tokio::test] + async fn invalid_schematic_scope_is_a_structured_argument_error() { + let result = handle_check_bom_health( + &json!({"schematic": "unused.kicad_sch", "schematic_scope": "project"}), + &test_ctx(), + ) + .await + .unwrap(); + assert!(result.is_error); + let crate::mcp::protocol::ToolContent::Text { text } = &result.content[0] else { + panic!("argument error must be text JSON") + }; + let body: Value = serde_json::from_str(text).unwrap(); + assert_eq!(body["error"]["kind"], "invalid_argument"); + assert_eq!(body["error"]["field"], "schematic_scope"); + } + + #[tokio::test] + async fn missing_or_cyclic_hierarchy_is_incomplete_with_diagnostics() { + let tmp = TempDir::new().unwrap(); + let missing_root = tmp.path().join("missing_root.kicad_sch"); + std::fs::write(&missing_root, root_with_child("missing.kicad_sch")).unwrap(); + + let missing = tool_json( + handle_check_bom_health( + &json!({ + "schematic": missing_root.display().to_string(), + "schematic_scope": "hierarchy" + }), + &test_ctx(), + ) + .await + .unwrap(), + ); + assert_eq!(missing["status"], "partial", "{missing}"); + assert!(missing["diagnostics"] + .as_array() + .unwrap() + .iter() + .any(|diagnostic| diagnostic["code"] == "hierarchy_error")); + + let cyclic_root = tmp.path().join("cyclic_root.kicad_sch"); + std::fs::write(&cyclic_root, root_with_child("cyclic_root.kicad_sch")).unwrap(); + let cyclic = tool_json( + handle_check_bom_health( + &json!({ + "schematic": cyclic_root.display().to_string(), + "schematic_scope": "hierarchy" + }), + &test_ctx(), + ) + .await + .unwrap(), + ); + assert_eq!(cyclic["status"], "partial", "{cyclic}"); + assert!(cyclic["diagnostics"] + .as_array() + .unwrap() + .iter() + .any(|diagnostic| diagnostic["message"] + .as_str() + .is_some_and(|message| message.contains("cycle detected")))); + } + #[tokio::test] async fn clean_single_sheet_can_still_look_good() { let tmp = TempDir::new().unwrap(); diff --git a/crates/konnect/assets/skills/kicad-review/SKILL.md b/crates/konnect/assets/skills/kicad-review/SKILL.md index 5274e4af..14d91ac1 100644 --- a/crates/konnect/assets/skills/kicad-review/SKILL.md +++ b/crates/konnect/assets/skills/kicad-review/SKILL.md @@ -141,10 +141,18 @@ Checks PCB-level rules: These go beyond rule checking — they evaluate design quality and best practices. +The standalone schematic audits and `check_bom_health` default to the supplied +file only. When the supplied file is a hierarchy root, pass +`schematic_scope: "hierarchy"` to cover every reachable sheet instance. Read +`status`, `coverage`, and `diagnostics` before interpreting a hierarchy result; +missing or cyclic child references make the result incomplete. Reused child +files have one result per KiCad sheet instance, identified by the +`sheet_instance_path` response field. + ### Decoupling Audit ``` -audit_decoupling() +audit_decoupling(schematic, schematic_scope="hierarchy") ``` Checks: @@ -156,7 +164,7 @@ Checks: ### Connection Audit ``` -audit_connections() +audit_connections(schematic, schematic_scope="hierarchy") ``` Checks: @@ -168,7 +176,7 @@ Checks: ### Power Rail Audit ``` -audit_power_rails() +audit_power_rails(schematic, schematic_scope="hierarchy") ``` Checks: diff --git a/crates/konnect/assets/skills/kicad-review/references/design-checklist.md b/crates/konnect/assets/skills/kicad-review/references/design-checklist.md index 6dd147ae..9bb579d0 100644 --- a/crates/konnect/assets/skills/kicad-review/references/design-checklist.md +++ b/crates/konnect/assets/skills/kicad-review/references/design-checklist.md @@ -72,8 +72,9 @@ | Single-pin nets | `find_single_pin_nets` | | ERC violations | `run_erc` | | DRC violations | `get_drc_violations` | -| Decoupling audit | `audit_decoupling` | -| Connection audit | `audit_connections` | -| Power rail audit | `audit_power_rails` | +| Decoupling audit | `audit_decoupling(schematic_scope="hierarchy")` for a hierarchy root | +| Connection audit | `audit_connections(schematic_scope="hierarchy")` for a hierarchy root | +| Power rail audit | `audit_power_rails(schematic_scope="hierarchy")` for a hierarchy root | +| BOM health | `check_bom_health(schematic_scope="hierarchy")` for a hierarchy root | | DFM audit | `audit_manufacturing` | | Full review | `run_design_review` | diff --git a/crates/konnect/tests/asset_references.rs b/crates/konnect/tests/asset_references.rs index 91fede71..23445f81 100644 --- a/crates/konnect/tests/asset_references.rs +++ b/crates/konnect/tests/asset_references.rs @@ -697,6 +697,8 @@ fn backticked_tool_names_in_prose_exist_in_the_registry() { "unsafe_file_fallback", // Structured manufacturing response field, not a callable tool. "files_generated", + // Structured hierarchy-audit response field, not a callable tool. + "sheet_instance_path", ]; let mut phantom = Vec::new(); diff --git a/tool-directory.md b/tool-directory.md index d6d6cedb..2c2d4900 100644 --- a/tool-directory.md +++ b/tool-directory.md @@ -409,12 +409,12 @@ the router or relying on the KiCad ActionPlugin workflow. | Tool | Description | |------|-------------| -| `audit_decoupling` | Audit schematic connectivity between IC power nets and decoupling capacitors; does not measure PCB placement distance. | -| `audit_connections` | Check for common connection mistakes: missing pull-ups on I2C/reset, missing series resistors on LEDs, floating inputs, shorted outputs. | -| `audit_power_rails` | Check power rail integrity: missing bulk capacitance, no test points, missing regulator output caps. | +| `audit_decoupling` | Audit schematic connectivity between IC power nets and decoupling capacitors; does not measure PCB placement distance. Defaults to one file; `schematic_scope: hierarchy` covers every reachable sheet instance. | +| `audit_connections` | Check for common connection mistakes: missing pull-ups on I2C/reset, missing series resistors on LEDs, floating inputs, shorted outputs. Defaults to one file; `schematic_scope: hierarchy` covers every reachable sheet instance. | +| `audit_power_rails` | Check power rail integrity: missing bulk capacitance, no test points, missing regulator output caps. Defaults to one file; `schematic_scope: hierarchy` covers every reachable sheet instance. | | `audit_manufacturing` | DFM checks for the configured fab house: component spacing, silkscreen overlap, via-in-pad, acid traps, board-outline issues. | | `run_design_review` | Run all available audit checks across every reachable schematic sheet and produce a consolidated report with status, coverage, and diagnostics. Returns `INCOMPLETE` rather than approval when coverage is partial or failed. | -| `check_bom_health` | Analyze the BOM for supply-chain risks: parts with no MPN, lifecycle warnings, low stock, unavailable from preferred distributors. | +| `check_bom_health` | Analyze the BOM for supply-chain risks: parts with no MPN, lifecycle warnings, low stock, unavailable from preferred distributors. Defaults to one file; `schematic_scope: hierarchy` covers every reachable sheet instance. | --- From 02126a11a87324e3efec8d4f63ab904076a37161 Mon Sep 17 00:00:00 2001 From: dubesinhower Date: Sat, 29 Aug 2026 21:20:54 -0400 Subject: [PATCH 07/16] feat(runtime): report installation provenance --- .github/workflows/release.yml | 3 + DEV.md | 12 +- README.md | 7 + crates/konnect-core/build.rs | 91 +++++ crates/konnect-core/src/lib.rs | 1 + crates/konnect-core/src/router/meta_tools.rs | 23 +- crates/konnect-core/src/runtime_info.rs | 393 +++++++++++++++++++ crates/konnect/tests/asset_references.rs | 1 + crates/konnect/tests/protocol_stdio.rs | 46 +++ docs/ARCHITECTURE.md | 3 +- docs/TROUBLESHOOTING.md | 21 +- tool-directory.md | 10 +- 12 files changed, 599 insertions(+), 12 deletions(-) create mode 100644 crates/konnect-core/build.rs create mode 100644 crates/konnect-core/src/runtime_info.rs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dffbcae8..53a5edf0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,6 +12,9 @@ permissions: {} env: CARGO_TERM_COLOR: always BINARY_NAME: konnect + # Embedded into get_installation_info so release artifacts identify the + # exact source commit even when built outside a Git working tree. + KONNECT_BUILD_COMMIT: ${{ github.sha }} jobs: build: diff --git a/DEV.md b/DEV.md index 088991ae..b1eae223 100644 --- a/DEV.md +++ b/DEV.md @@ -77,7 +77,7 @@ Konnect/ │ │ ├── router/ │ │ │ ├── mod.rs # ToolRouter: load/unload toolsets │ │ │ ├── registry.rs # Static toolset metadata + tools_for() dispatcher -│ │ │ └── meta_tools.rs # 6 always-visible meta-tools +│ │ │ └── meta_tools.rs # 7 always-visible meta-tools │ │ └── tools/ │ │ ├── mod.rs # ToolDef, ToolContext, tool! macro, helpers, kicad_config_dir() │ │ ├── cli.rs # kicad-cli v10 subprocess wrapper (verified against actual binary) @@ -316,9 +316,9 @@ Source: [`crates/konnect-core/src/observability.rs`](crates/konnect-core/src/obs ## Tool Routing (Starter Kit + On-Demand Loading) -The server does NOT expose all 221 tools (227 total with the 6 meta-tools) in `tools/list` by default — that would cost ~23K tokens of context on every listing. Instead: +The server does NOT expose all 221 tools (228 total with the 7 meta-tools) in `tools/list` by default — that would cost ~23K tokens of context on every listing. Instead: -- **Startup**: only `STARTER_KIT` toolsets are pre-loaded (see `router/registry.rs::STARTER_KIT`). Currently: `project`, `config`. Combined with the 6 meta-tools, baseline `tools/list` is 20 tools ≈ 2K tokens. +- **Startup**: only `STARTER_KIT` toolsets are pre-loaded (see `router/registry.rs::STARTER_KIT`). Currently: `project`, `config`. Combined with the 7 meta-tools, baseline `tools/list` is 21 tools ≈ 2K tokens. - **On demand**: the LLM reads `list_toolboxes` → calls `load_toolset(name)` to expose a toolset's tools in subsequent `tools/list` responses. `unload_toolset(name)` prunes them when the task shifts. - **`tools/list_changed` notification**: sent on every load/unload so MCP clients refresh their local tool cache. - **Error recovery**: if the LLM calls an unloaded tool, `handler.rs` returns an actionable error naming the toolset that owns it (so the LLM can load it and retry in one hop — no extra `list_toolboxes` round-trip). @@ -391,9 +391,9 @@ convention for other `kicad-cli`-calling code. ## Current Stats -- **20 toolsets, 221 tools** + 6 meta-tools (4 routing + 2 observability — see `tool-directory.md`) -- Baseline `tools/list`: 20 tools / ~2K tokens (starter kit + meta-tools) -- Full-catalog `tools/list` (all loaded): 227 tools (221 registered + 6 meta) / ~25K tokens +- **20 toolsets, 221 tools** + 7 meta-tools (4 routing + 2 observability + 1 runtime diagnostic — see `tool-directory.md`) +- Baseline `tools/list`: 21 tools / ~2K tokens (starter kit + meta-tools) +- Full-catalog `tools/list` (all loaded): 228 tools (221 registered + 7 meta) / ~25K tokens - **0 IPC stubs** (all protobuf methods implemented) - **0 unimplemented tools** - **Specctra DSN/SES are PCB-editor operations**, not `kicad-cli` commands. diff --git a/README.md b/README.md index 734ec8f0..ffb1e15b 100644 --- a/README.md +++ b/README.md @@ -191,6 +191,13 @@ argument Konnect does not recognise is an error rather than being ignored, so a typo such as `--cleint codex` stops instead of quietly installing for the default client. +To verify which Konnect process an MCP client is actually using, call the +always-visible `get_installation_info` tool. It reports the serving build, +executable path, verified installation source when one can be proven, KiCad +CLI and IPC detection, and restart guidance. A missing build commit or an +`unknown` installation source means the available evidence was insufficient; +it is not silently guessed from a directory name. + ### macOS The [Releases](https://github.com/mixelpixx/Konnect/releases) page ships diff --git a/crates/konnect-core/build.rs b/crates/konnect-core/build.rs new file mode 100644 index 00000000..bfdcec9b --- /dev/null +++ b/crates/konnect-core/build.rs @@ -0,0 +1,91 @@ +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; + +fn main() { + println!("cargo:rerun-if-env-changed=KONNECT_BUILD_COMMIT"); + + let manifest_dir = env::var("CARGO_MANIFEST_DIR").expect("Cargo sets CARGO_MANIFEST_DIR"); + let repo_root = Path::new(&manifest_dir) + .join("../..") + .canonicalize() + .unwrap_or_else(|_| Path::new(&manifest_dir).join("../..")); + + let (commit, source) = match env::var("KONNECT_BUILD_COMMIT") + .ok() + .filter(|value| is_commit_id(value)) + { + Some(commit) => (Some(commit), "build_environment"), + None => (commit_from_git_files(&repo_root), "git_head"), + }; + + if let Some(commit) = commit { + println!("cargo:rustc-env=KONNECT_BUILD_COMMIT={commit}"); + println!("cargo:rustc-env=KONNECT_BUILD_COMMIT_SOURCE={source}"); + } +} + +/// Read Git's public repository metadata directly so source builds do not +/// depend on a `git` executable being available to Cargo build scripts. +fn commit_from_git_files(repo_root: &Path) -> Option { + let git_dir = resolve_git_dir(repo_root)?; + let head_path = git_dir.join("HEAD"); + println!("cargo:rerun-if-changed={}", head_path.display()); + let head = fs::read_to_string(&head_path).ok()?; + let head = head.trim(); + if is_commit_id(head) { + return Some(head.to_string()); + } + + let reference = head.strip_prefix("ref: ")?.trim(); + let common_dir = resolve_common_dir(&git_dir); + for root in [&git_dir, &common_dir] { + let reference_path = root.join(reference); + println!("cargo:rerun-if-changed={}", reference_path.display()); + if let Ok(value) = fs::read_to_string(&reference_path) { + let value = value.trim(); + if is_commit_id(value) { + return Some(value.to_string()); + } + } + } + + let packed_refs = common_dir.join("packed-refs"); + println!("cargo:rerun-if-changed={}", packed_refs.display()); + let packed = fs::read_to_string(packed_refs).ok()?; + packed.lines().find_map(|line| { + let (commit, name) = line.split_once(' ')?; + (name == reference && is_commit_id(commit)).then(|| commit.to_string()) + }) +} + +fn resolve_git_dir(repo_root: &Path) -> Option { + let dot_git = repo_root.join(".git"); + if dot_git.is_dir() { + return Some(dot_git); + } + let pointer = fs::read_to_string(dot_git).ok()?; + let value = pointer.trim().strip_prefix("gitdir: ")?; + let path = PathBuf::from(value); + Some(if path.is_absolute() { + path + } else { + repo_root.join(path) + }) +} + +fn resolve_common_dir(git_dir: &Path) -> PathBuf { + let Ok(value) = fs::read_to_string(git_dir.join("commondir")) else { + return git_dir.to_path_buf(); + }; + let path = PathBuf::from(value.trim()); + if path.is_absolute() { + path + } else { + git_dir.join(path) + } +} + +fn is_commit_id(value: &str) -> bool { + (7..=64).contains(&value.len()) && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} diff --git a/crates/konnect-core/src/lib.rs b/crates/konnect-core/src/lib.rs index 5a6877a4..5c098faa 100644 --- a/crates/konnect-core/src/lib.rs +++ b/crates/konnect-core/src/lib.rs @@ -6,6 +6,7 @@ pub mod mcp; pub(crate) mod native_specctra_bridge; pub mod observability; pub mod router; +pub(crate) mod runtime_info; pub(crate) mod specctra; pub(crate) mod specctra_ses; pub mod tools; diff --git a/crates/konnect-core/src/router/meta_tools.rs b/crates/konnect-core/src/router/meta_tools.rs index 9d1ff8cd..8c952cb9 100644 --- a/crates/konnect-core/src/router/meta_tools.rs +++ b/crates/konnect-core/src/router/meta_tools.rs @@ -1,4 +1,4 @@ -//! The 6 always-visible meta-tools. +//! The 7 always-visible meta-tools. //! //! Discovery / routing: //! list_toolboxes() — show every toolset with descriptions and load state @@ -9,6 +9,7 @@ //! Observability: //! get_recent_calls(limit?) — last N tool calls (newest first) with timing + status //! server_stats() — uptime, per-tool totals/errors, JSONL log path +//! get_installation_info() — serving build, binary, install, KiCad, and IPC provenance //! //! At server startup only the STARTER_KIT (`project`, `config`) is pre-loaded so //! baseline context stays small. The LLM reads `list_toolboxes` and calls @@ -19,7 +20,7 @@ use crate::mcp::protocol::{CallToolResult, McpToolDescription}; use crate::tools::ToolContext; use serde_json::{json, Value}; -/// Return the 6 meta-tool MCP descriptions (always in the tools/list response). +/// Return the 7 meta-tool MCP descriptions (always in the tools/list response). pub fn meta_tool_descriptions() -> Vec { vec![ McpToolDescription { @@ -120,6 +121,20 @@ pub fn meta_tool_descriptions() -> Vec { "required": [] }), }, + McpToolDescription { + name: "get_installation_info".to_string(), + description: + "Report read-only provenance for the Konnect process serving this call: build \ + version and commit when available, executable path, conservatively detected \ + install source, on-disk binary version, KiCad CLI version, redacted IPC \ + endpoint, proven newer-binary state, and platform-specific restart guidance." + .to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "required": [] + }), + }, ] } @@ -136,6 +151,10 @@ pub async fn handle_meta_tool( "get_active_toolsets" => Some(handle_get_active_toolsets(ctx).await), "get_recent_calls" => Some(handle_get_recent_calls(args, ctx).await), "server_stats" => Some(handle_server_stats(ctx).await), + "get_installation_info" => { + let info = crate::runtime_info::collect(&ctx.config).await; + Some(CallToolResult::json(&info)) + } _ => None, } } diff --git a/crates/konnect-core/src/runtime_info.rs b/crates/konnect-core/src/runtime_info.rs new file mode 100644 index 00000000..f4e540ed --- /dev/null +++ b/crates/konnect-core/src/runtime_info.rs @@ -0,0 +1,393 @@ +//! Read-only runtime and installation provenance for the serving process. + +use crate::tools::ServerConfig; +use serde_json::{json, Value}; +use std::cmp::Ordering; +use std::path::{Path, PathBuf}; +use std::time::Duration; +use tokio::process::Command; + +const COMMAND_TIMEOUT: Duration = Duration::from_secs(5); +const PCM_IDENTIFIER: &str = "com.github.mixelpixx.konnect"; + +pub(crate) async fn collect(config: &ServerConfig) -> Value { + let running_version = env!("CARGO_PKG_VERSION"); + let executable_path = std::env::current_exe().ok(); + let installation = executable_path + .as_deref() + .map(classify_installation) + .unwrap_or_else(InstallSource::unavailable); + + let binary_probe = match executable_path.as_deref() { + Some(path) => probe_command_version(path, VersionCommand::Konnect).await, + None => VersionProbe::unavailable(), + }; + let newer_than_running = binary_probe + .version + .as_deref() + .and_then(|version| stable_version_cmp(version, running_version)) + .map(|ordering| ordering == Ordering::Greater); + + let kicad_cli_path = crate::kicad_install::find_cli(&config.kicad_cli); + let kicad_probe = match kicad_cli_path.as_deref() { + Some(path) => probe_command_version(path, VersionCommand::KiCad).await, + None => VersionProbe::not_found(), + }; + + let ipc_endpoint = if config.ipc_address.trim().is_empty() { + None + } else { + Some(redact_endpoint(config.ipc_address.trim())) + }; + + json!({ + "build": { + "version": running_version, + "commit": option_env!("KONNECT_BUILD_COMMIT"), + "commit_source": option_env!("KONNECT_BUILD_COMMIT_SOURCE"), + "working_tree_state": "not_recorded", + "profile": if cfg!(debug_assertions) { "debug" } else { "release" }, + "target_os": std::env::consts::OS, + "target_arch": std::env::consts::ARCH, + }, + "runtime": { + "executable_path": executable_path.as_deref().map(display_path), + }, + "installation": { + "source": installation.name, + "evidence": installation.evidence, + "manifest_path": installation.manifest_path.as_deref().map(display_path), + "binary_on_disk": { + "probe_status": binary_probe.status, + "version": binary_probe.version, + "newer_than_running": newer_than_running, + }, + }, + "kicad": { + "cli_path": kicad_cli_path.as_deref().map(display_path), + "probe_status": kicad_probe.status, + "version": kicad_probe.version, + }, + "ipc": { + "configured": ipc_endpoint.is_some(), + "source": "resolved_server_config", + "endpoint": ipc_endpoint, + }, + "restart_guidance": restart_guidance(installation.name, newer_than_running), + }) +} + +#[derive(Debug)] +struct InstallSource { + name: &'static str, + evidence: &'static str, + manifest_path: Option, +} + +impl InstallSource { + fn unavailable() -> Self { + Self { + name: "unknown", + evidence: + "The serving executable path could not be resolved; no install source was inferred.", + manifest_path: None, + } + } +} + +fn classify_installation(executable_path: &Path) -> InstallSource { + let manifest_path = executable_path + .parent() + .and_then(Path::parent) + .map(|plugin_dir| plugin_dir.join("plugin.json")); + + if let Some(path) = manifest_path.filter(|path| is_konnect_pcm_manifest(path)) { + return InstallSource { + name: "kicad_pcm", + evidence: + "A sibling KiCad executable-plugin manifest has Konnect's exact public identifier.", + manifest_path: Some(path), + }; + } + + InstallSource { + name: "unknown", + evidence: "No verified KiCad PCM manifest was found beside this executable; standalone and source builds are intentionally not guessed from path names.", + manifest_path: None, + } +} + +fn is_konnect_pcm_manifest(path: &Path) -> bool { + let Ok(raw) = std::fs::read_to_string(path) else { + return false; + }; + let Ok(manifest) = serde_json::from_str::(&raw) else { + return false; + }; + manifest.get("identifier").and_then(Value::as_str) == Some(PCM_IDENTIFIER) + && manifest + .get("runtime") + .and_then(|runtime| runtime.get("type")) + .and_then(Value::as_str) + == Some("exec") +} + +#[derive(Clone, Copy)] +enum VersionCommand { + Konnect, + KiCad, +} + +struct VersionProbe { + status: &'static str, + version: Option, +} + +impl VersionProbe { + fn unavailable() -> Self { + Self { + status: "executable_path_unavailable", + version: None, + } + } + + fn not_found() -> Self { + Self { + status: "not_found", + version: None, + } + } +} + +async fn probe_command_version(path: &Path, command_kind: VersionCommand) -> VersionProbe { + let mut command = Command::new(path); + command.arg("--version").kill_on_drop(true); + let output = match tokio::time::timeout(COMMAND_TIMEOUT, command.output()).await { + Ok(Ok(output)) => output, + Ok(Err(_)) => { + return VersionProbe { + status: "launch_failed", + version: None, + }; + } + Err(_) => { + return VersionProbe { + status: "timed_out", + version: None, + }; + } + }; + + if !output.status.success() { + return VersionProbe { + status: "nonzero_exit", + version: None, + }; + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let line = stdout + .lines() + .chain(stderr.lines()) + .map(str::trim) + .find(|line| !line.is_empty()); + + let version = match command_kind { + VersionCommand::Konnect => line.and_then(parse_konnect_version).map(str::to_string), + VersionCommand::KiCad => line.and_then(sanitize_version_line), + }; + VersionProbe { + status: if version.is_some() { + "ok" + } else { + "unrecognized_output" + }, + version, + } +} + +fn parse_konnect_version(line: &str) -> Option<&str> { + let version = line.strip_prefix("konnect ")?.trim(); + (!version.is_empty() && !version.chars().any(char::is_whitespace)).then_some(version) +} + +fn sanitize_version_line(line: &str) -> Option { + let value: String = line + .chars() + .filter(|ch| !ch.is_control()) + .take(200) + .collect(); + (!value.is_empty()).then_some(value) +} + +fn stable_version_cmp(candidate: &str, running: &str) -> Option { + fn stable_triplet(version: &str) -> Option<[u64; 3]> { + if version.contains(['-', '+']) { + return None; + } + let values = version + .strip_prefix('v') + .unwrap_or(version) + .split('.') + .map(str::parse::) + .collect::, _>>() + .ok()?; + (values.len() == 3).then(|| [values[0], values[1], values[2]]) + } + + Some(stable_triplet(candidate)?.cmp(&stable_triplet(running)?)) +} + +fn redact_endpoint(endpoint: &str) -> String { + let (without_fragment, had_fragment) = endpoint + .split_once('#') + .map_or((endpoint, false), |(head, _)| (head, true)); + let (without_query, had_query) = without_fragment + .split_once('?') + .map_or((without_fragment, false), |(head, _)| (head, true)); + + let without_credentials = if let Some((scheme, rest)) = without_query.split_once("://") { + if let Some((_, authority_and_path)) = rest.split_once('@') { + format!("{scheme}://[redacted]@{authority_and_path}") + } else { + without_query.to_string() + } + } else { + without_query.to_string() + }; + + if had_query || had_fragment { + format!("{without_credentials} [query/fragment redacted]") + } else { + without_credentials + } +} + +fn restart_guidance(source: &str, newer_than_running: Option) -> Vec { + let mut guidance = Vec::new(); + if newer_than_running == Some(true) { + guidance.push( + "A newer binary is proven at the serving executable path; restart the process before relying on the new build." + .to_string(), + ); + } + + #[cfg(target_os = "windows")] + guidance.push( + "Windows: exit every MCP client or KiCad session that launched Konnect, then reopen the owning application; running executables may remain locked during an update." + .to_string(), + ); + #[cfg(target_os = "macos")] + guidance.push( + "macOS: restart the MCP client that launched Konnect; if KiCad launched it, quit and reopen KiCad after the update." + .to_string(), + ); + #[cfg(target_os = "linux")] + guidance.push( + "Linux: restart the MCP client that launched Konnect; if KiCad launched it, stop the plugin server or quit and reopen KiCad after the update." + .to_string(), + ); + #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] + guidance.push( + "Restart the MCP client or KiCad session that launched Konnect after replacing the binary." + .to_string(), + ); + + if source == "kicad_pcm" { + guidance.push( + "KiCad PCM install detected: complete the Plugin and Content Manager update, then restart KiCad and any separately configured MCP client." + .to_string(), + ); + } + guidance.push( + "Call get_installation_info again after restart and verify the serving version, commit, and executable path." + .to_string(), + ); + guidance +} + +fn display_path(path: &Path) -> String { + path.to_string_lossy().into_owned() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn verified_pcm_manifest_is_required_for_pcm_classification() { + let temp = tempfile::tempdir().unwrap(); + let plugin_dir = temp.path().join("plugins"); + let bin_dir = plugin_dir.join("bin"); + std::fs::create_dir_all(&bin_dir).unwrap(); + let executable = bin_dir.join(if cfg!(windows) { + "konnect.exe" + } else { + "konnect" + }); + std::fs::write(&executable, b"").unwrap(); + + assert_eq!(classify_installation(&executable).name, "unknown"); + std::fs::write( + plugin_dir.join("plugin.json"), + r#"{"identifier":"someone.else","runtime":{"type":"exec"}}"#, + ) + .unwrap(); + assert_eq!(classify_installation(&executable).name, "unknown"); + std::fs::write( + plugin_dir.join("plugin.json"), + r#"{"identifier":"com.github.mixelpixx.konnect","runtime":{"type":"exec"}}"#, + ) + .unwrap(); + + let source = classify_installation(&executable); + assert_eq!(source.name, "kicad_pcm"); + assert_eq!(source.manifest_path, Some(plugin_dir.join("plugin.json"))); + } + + #[test] + fn endpoint_redaction_removes_credentials_query_and_fragment() { + assert_eq!( + redact_endpoint("tcp://user:secret@127.0.0.1:9000/api?token=hidden#detail"), + "tcp://[redacted]@127.0.0.1:9000/api [query/fragment redacted]" + ); + assert_eq!( + redact_endpoint("ipc:///tmp/kicad/api.sock"), + "ipc:///tmp/kicad/api.sock" + ); + } + + #[test] + fn newer_claim_requires_comparable_stable_versions() { + assert_eq!( + stable_version_cmp("0.12.0", "0.11.9"), + Some(Ordering::Greater) + ); + assert_eq!( + stable_version_cmp("0.11.0", "0.11.0"), + Some(Ordering::Equal) + ); + assert_eq!(stable_version_cmp("0.11.0-beta.1", "0.10.0"), None); + assert_eq!(stable_version_cmp("not-a-version", "0.11.0"), None); + } + + #[test] + fn embedded_commit_is_hex_when_available() { + if let Some(commit) = option_env!("KONNECT_BUILD_COMMIT") { + assert!((7..=64).contains(&commit.len())); + assert!(commit.bytes().all(|byte| byte.is_ascii_hexdigit())); + } + } + + #[test] + fn restart_guidance_names_the_current_platform() { + let guidance = restart_guidance("unknown", None).join("\n"); + #[cfg(target_os = "windows")] + assert!(guidance.contains("Windows:")); + #[cfg(target_os = "macos")] + assert!(guidance.contains("macOS:")); + #[cfg(target_os = "linux")] + assert!(guidance.contains("Linux:")); + } +} diff --git a/crates/konnect/tests/asset_references.rs b/crates/konnect/tests/asset_references.rs index 23445f81..236e39d2 100644 --- a/crates/konnect/tests/asset_references.rs +++ b/crates/konnect/tests/asset_references.rs @@ -649,6 +649,7 @@ fn backticked_tool_names_in_prose_exist_in_the_registry() { "get_active_toolsets", "get_recent_calls", "server_stats", + "get_installation_info", "auto_load_toolsets", "eager_toolsets", "kicad_cli", diff --git a/crates/konnect/tests/protocol_stdio.rs b/crates/konnect/tests/protocol_stdio.rs index 62285cf1..939acc6b 100644 --- a/crates/konnect/tests/protocol_stdio.rs +++ b/crates/konnect/tests/protocol_stdio.rs @@ -180,6 +180,52 @@ fn handshake_baseline_and_full_registry_loads() { ); } +#[test] +fn installation_info_reports_the_serving_process_without_leaking_endpoint_secrets() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write( + tmp.path().join("konnect.toml"), + "ipc_address = \"ipc://diagnostic-test.sock?token=secret#fragment\"\n", + ) + .unwrap(); + let mut p = McpProcess::spawn_in_dir(Some(tmp.path())); + + let list = p.request("tools/list", json!({})); + assert!(list["result"]["tools"] + .as_array() + .unwrap() + .iter() + .any(|tool| tool["name"] == "get_installation_info")); + + let result = p.call_tool("get_installation_info", json!({})); + assert_ne!(result["isError"], json!(true), "{result:#?}"); + let body = McpProcess::tool_body(&result); + + assert_eq!(body["build"]["version"], env!("CARGO_PKG_VERSION")); + let commit = body["build"]["commit"].as_str().unwrap(); + assert!((7..=64).contains(&commit.len())); + assert!(commit.bytes().all(|byte| byte.is_ascii_hexdigit())); + assert!(body["runtime"]["executable_path"].is_string()); + assert_eq!(body["installation"]["binary_on_disk"]["probe_status"], "ok"); + assert_eq!( + body["installation"]["binary_on_disk"]["version"], + env!("CARGO_PKG_VERSION") + ); + assert_eq!( + body["installation"]["binary_on_disk"]["newer_than_running"], + false + ); + assert_eq!(body["ipc"]["configured"], true); + assert_eq!( + body["ipc"]["endpoint"], + "ipc://diagnostic-test.sock [query/fragment redacted]" + ); + let serialized = serde_json::to_string(&body).unwrap(); + assert!(!serialized.contains("token=secret"), "{body:#?}"); + assert!(!serialized.contains("#fragment"), "{body:#?}"); + assert!(!body["restart_guidance"].as_array().unwrap().is_empty()); +} + #[test] fn file_based_tool_roundtrip_in_temp_project() { let tmp = tempfile::tempdir().unwrap(); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b4e13dbb..27098ccb 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -28,7 +28,8 @@ records calls through `observability.rs`. `crates/konnect-core/src/router/registry.rs` declares toolsets and resolves each toolset to its definitions. `router/mod.rs` tracks loaded definitions, and `router/meta_tools.rs` implements the always-visible discovery, loading, and -observability tools. +observability tools. `runtime_info.rs` supplies the read-only serving-build, +installation, KiCad, and IPC evidence returned by `get_installation_info`. `crates/konnect-core/src/tools/mod.rs` owns `ToolDef`, `ToolContext`, `ServerConfig`, the `tool!` macro, required-argument helpers, and shared path and diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 5b0fdb06..c3a9ba6b 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -1,5 +1,24 @@ # Troubleshooting +## Which Konnect binary is this client using? + +Call the always-visible `get_installation_info` tool in the affected MCP +session. The result comes from the process serving that call and includes its +version, build commit when available, executable path, conservatively detected +install source, the version produced by the binary currently on disk at that +same path, KiCad CLI version, redacted IPC endpoint, and restart guidance. + +`installation.binary_on_disk.newer_than_running: true` is reported only when +both stable versions can be parsed and the on-disk binary is newer. `null` +means the comparison could not be proven, not that the process is current. +Likewise, `installation.source: "unknown"` means no trusted package manifest +identified the channel; Konnect does not guess from directory names. Endpoint +credentials and query or fragment data are redacted. + +Follow the returned platform-specific guidance, restart the MCP client (and +KiCad when it owns the server process), then call `get_installation_info` again +to verify the process that actually restarted. This diagnostic writes nothing. + ## "KiCAD IPC socket path not configured" Any tool that talks to a live KiCAD session (`save_project`, PCB editing, @@ -246,7 +265,7 @@ callable tools, the fix is to make the *first* listing complete: ``` in `konnect.toml` in the working directory, or a `settings.json` beside the binary. Every toolset is then loaded at -startup, so `tools/list` carries all 227 tools from the first call. +startup, so `tools/list` carries all 228 tools from the first call. It is off by default because it costs what the router exists to save: roughly 25K tokens per listing instead of ~2K. Turn it on only if your client needs it. diff --git a/tool-directory.md b/tool-directory.md index 2c2d4900..b4fcc532 100644 --- a/tool-directory.md +++ b/tool-directory.md @@ -13,13 +13,13 @@ Compatibility notes for removed or narrowed arguments are recorded in ## Overview - **20 toolsets** organized into 10 categories -- **221 registered tools** + **6 always-visible meta-tools** = **227 total** +- **221 registered tools** + **7 always-visible meta-tools** = **228 total** - **Discovery pattern**: the server pre-loads only the **starter kit** (`project`, `config`) so baseline `tools/list` costs ~2K tokens instead of ~23K. The LLM reads `list_toolboxes` → calls `load_toolset(name)` to expose additional tools on demand; `unload_toolset(name)` prunes them. `tools/list_changed` is notified on every mutation. If the LLM calls a tool whose toolset isn't loaded, the error names the owning toolset so recovery is a single `load_toolset` hop. `load_toolset` also accepts an array of names to load several toolsets with a single `tools/list` refresh. - **Observability**: every `tools/call` is recorded — ring buffer of the last 100 calls + per-tool counters + JSONL at `/logs/calls.jsonl`. The LLM self-diagnoses via `get_recent_calls` and `server_stats`. ## Meta-tools (always visible) -Six tools, grouped into *discovery/routing* and *observability*. +Seven tools, grouped into *discovery/routing*, *observability*, and *runtime diagnostics*. ### Discovery / routing @@ -37,6 +37,12 @@ Six tools, grouped into *discovery/routing* and *observability*. | `get_recent_calls` | Last N tool calls (newest first) — `call_id`, tool, toolset, duration, status (ok/error/not_found), `error_kind`. The LLM's debug log. Default limit 20, max 100. | | `server_stats` | Uptime, total/error call counts, per-tool totals + errors, and the JSONL log path. | +### Runtime diagnostics + +| Tool | Purpose | +|------|---------| +| `get_installation_info` | Report the serving build version and commit, executable path, verified install source, on-disk binary version, KiCad CLI version, redacted IPC endpoint, proven stale-process evidence, and platform-specific restart guidance. | + --- ## Project From 477631ce62465307c6e70a900bbd54f419c31faa Mon Sep 17 00:00:00 2001 From: dubesinhower Date: Sat, 29 Aug 2026 21:43:41 -0400 Subject: [PATCH 08/16] test(runtime): allow missing source commit metadata --- crates/konnect/tests/protocol_stdio.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/konnect/tests/protocol_stdio.rs b/crates/konnect/tests/protocol_stdio.rs index 939acc6b..5894980c 100644 --- a/crates/konnect/tests/protocol_stdio.rs +++ b/crates/konnect/tests/protocol_stdio.rs @@ -202,9 +202,13 @@ fn installation_info_reports_the_serving_process_without_leaking_endpoint_secret let body = McpProcess::tool_body(&result); assert_eq!(body["build"]["version"], env!("CARGO_PKG_VERSION")); - let commit = body["build"]["commit"].as_str().unwrap(); - assert!((7..=64).contains(&commit.len())); - assert!(commit.bytes().all(|byte| byte.is_ascii_hexdigit())); + if let Some(commit) = body["build"]["commit"].as_str() { + assert!((7..=64).contains(&commit.len())); + assert!(commit.bytes().all(|byte| byte.is_ascii_hexdigit())); + assert!(body["build"]["commit_source"].is_string()); + } else { + assert!(body["build"]["commit_source"].is_null()); + } assert!(body["runtime"]["executable_path"].is_string()); assert_eq!(body["installation"]["binary_on_disk"]["probe_status"], "ok"); assert_eq!( From 09d38d9f3f24a7facffbbe7434d59f87170f8119 Mon Sep 17 00:00:00 2001 From: dubesinhower Date: Sun, 30 Aug 2026 09:31:45 -0400 Subject: [PATCH 09/16] fix(project): resolve schematic ownership structurally --- crates/konnect-core/src/mcp/error.rs | 11 + crates/konnect-core/src/tools/library.rs | 88 ++-- crates/konnect-core/src/tools/mod.rs | 439 +++++++++++++++--- crates/konnect-core/src/tools/sch_batch.rs | 5 +- .../konnect-core/src/tools/sch_components.rs | 83 +++- crates/konnect-core/src/tools/sch_export.rs | 76 +-- crates/konnect-core/src/tools/sch_wiring.rs | 5 +- 7 files changed, 536 insertions(+), 171 deletions(-) diff --git a/crates/konnect-core/src/mcp/error.rs b/crates/konnect-core/src/mcp/error.rs index b783d68e..d0c54095 100644 --- a/crates/konnect-core/src/mcp/error.rs +++ b/crates/konnect-core/src/mcp/error.rs @@ -51,6 +51,12 @@ pub enum ToolErrorKind { FileNotFound { path: String }, /// A mutation would replace one or more existing filesystem targets. Conflict { paths: Vec }, + /// More than one project, document, or hierarchy instance can satisfy the + /// requested target and choosing one would be nondeterministic. + AmbiguousTarget { + target: String, + candidates: Vec, + }, /// A board was live earlier in this server process, but IPC is now gone; /// its saved file may be stale relative to lost editor state. UnsafeFileFallback { path: String }, @@ -70,6 +76,7 @@ impl ToolErrorKind { Self::InvalidArgument { .. } => "invalid_argument", Self::FileNotFound { .. } => "file_not_found", Self::Conflict { .. } => "conflict", + Self::AmbiguousTarget { .. } => "ambiguous_target", Self::UnsafeFileFallback { .. } => "unsafe_file_fallback", Self::HandlerError { .. } => "handler_error", } @@ -170,6 +177,10 @@ mod tests { ToolErrorKind::Conflict { paths: vec!["p".into()], }, + ToolErrorKind::AmbiguousTarget { + target: "p".into(), + candidates: vec!["a".into(), "b".into()], + }, ToolErrorKind::UnsafeFileFallback { path: "p".into() }, ToolErrorKind::HandlerError { reason: "r".into() }, ]; diff --git a/crates/konnect-core/src/tools/library.rs b/crates/konnect-core/src/tools/library.rs index 157440c7..6b0b68e6 100644 --- a/crates/konnect-core/src/tools/library.rs +++ b/crates/konnect-core/src/tools/library.rs @@ -1157,30 +1157,33 @@ fn global_sym_lib_table() -> PathBuf { super::kicad_config_dir().join("sym-lib-table") } -/// Directory of the nearest ancestor of `file` that holds a `.kicad_pro`, -/// falling back to the file's own directory when it belongs to no project — a -/// loose schematic keeps resolving against the tables beside it. +/// Directory of the structurally proven project that owns `file`, falling back +/// to the file's own directory when it belongs to no project — a loose +/// schematic keeps resolving against the tables beside it. /// /// The project file is found by scanning for the extension rather than by name: /// a sheet's filename says nothing about what the project is called. /// -/// A library table sitting beside `file` ends the search before it starts. The -/// ancestor walk is unbounded, so without that it can leave the file's own -/// directory and latch onto an unrelated `.kicad_pro` further up — a project -/// nested inside another project's folder, or a stray file in a shared parent — -/// and then resolve every library against the wrong `KIPRJMOD`. A directory -/// carrying its own `sym-lib-table` or `fp-lib-table` is stating where its -/// libraries come from, and that is the more specific answer. -pub(crate) fn project_root_for(file: &Path) -> Option { - let start = file.parent()?; +/// A library table sitting beside `file` is the most specific answer. An exact +/// sibling `.kicad_pro` is also authoritative. Otherwise a candidate +/// ancestor project is accepted only when its parsed root schematic reaches +/// this file. Multiple owners are returned as a typed ambiguity rather than a +/// directory-enumeration-order guess (#189). +pub(crate) fn project_root_for( + file: &Path, +) -> Result, crate::tools::SchematicTargetError> { + let Some(start) = file.parent() else { + return Ok(None); + }; if holds_lib_table(start) { - return Some(start.to_path_buf()); + return Ok(Some(start.to_path_buf())); } - start - .ancestors() - .find(|dir| holds_kicad_pro(dir)) - .map(Path::to_path_buf) - .or_else(|| Some(start.to_path_buf())) + if file.with_extension("kicad_pro").is_file() { + return Ok(Some(start.to_path_buf())); + } + Ok(crate::tools::resolve_schematic_ownership(file)? + .and_then(|ownership| ownership.project_file.parent().map(Path::to_path_buf)) + .or_else(|| Some(start.to_path_buf()))) } /// Whether `dir` carries a library table of its own. @@ -1188,17 +1191,6 @@ fn holds_lib_table(dir: &Path) -> bool { dir.join("sym-lib-table").is_file() || dir.join("fp-lib-table").is_file() } -/// Whether `dir` contains a `.kicad_pro`. An unreadable directory holds none. -fn holds_kicad_pro(dir: &Path) -> bool { - std::fs::read_dir(dir).is_ok_and(|entries| { - entries.flatten().any(|e| { - e.path() - .extension() - .is_some_and(|ext| ext.eq_ignore_ascii_case("kicad_pro")) - }) - }) -} - /// Symbol libraries resolved as KiCad does: project `sym-lib-table` (shadowing /// same-nickname global entries), then global, then the conventional /// `.kicad_symdir` / `.kicad_sym` layout. Same order as @@ -1228,8 +1220,8 @@ impl KiCadSymbolSource { /// hierarchical sheet under `/sheets/` therefore still resolves /// against `/sym-lib-table`: KiCad anchors `KIPRJMOD` at the project, /// not at the sheet. - pub(crate) fn for_file(file: &Path) -> Self { - Self::new(project_root_for(file)) + pub(crate) fn for_file(file: &Path) -> Result { + Ok(Self::new(project_root_for(file)?)) } /// Project entries first so they shadow same-nickname global ones. @@ -7304,8 +7296,30 @@ mod symbol_source_tests { std::fs::create_dir_all(&sheets).unwrap(); let child = sheets.join("child.kicad_sch"); std::fs::write(&child, "(kicad_sch)\n").unwrap(); + std::fs::write( + proj.path().join("board.kicad_sch"), + r#"(kicad_sch + (version 20250610) + (generator "eeschema") + (uuid "root-uuid") + (paper "A4") + (lib_symbols) + (sheet + (at 20 20) + (size 40 20) + (uuid "child-instance") + (property "Sheetname" "Child" (at 20 19.365 0)) + (property "Sheetfile" "sheets/child.kicad_sch" (at 20 40.635 0)) + ) + (sheet_instances (path "/" (page "1"))) +) +"#, + ) + .unwrap(); - let candidates = KiCadSymbolSource::for_file(&child).candidates("MyLib"); + let candidates = KiCadSymbolSource::for_file(&child) + .unwrap() + .candidates("MyLib"); assert!( candidates.contains(&file), "a sub-sheet must see the project table at the root, got {candidates:?}" @@ -7327,7 +7341,9 @@ mod symbol_source_tests { let sch = dir.path().join("loose.kicad_sch"); std::fs::write(&sch, "(kicad_sch)\n").unwrap(); - let candidates = KiCadSymbolSource::for_file(&sch).candidates("Loose"); + let candidates = KiCadSymbolSource::for_file(&sch) + .unwrap() + .candidates("Loose"); assert!( candidates.contains(&file), "a projectless schematic must still use the table beside it, got {candidates:?}" @@ -7366,7 +7382,9 @@ mod symbol_source_tests { let sch = inner.join("nested.kicad_sch"); std::fs::write(&sch, "(kicad_sch)\n").unwrap(); - let candidates = KiCadSymbolSource::for_file(&sch).candidates("Shared"); + let candidates = KiCadSymbolSource::for_file(&sch) + .unwrap() + .candidates("Shared"); assert!( candidates.contains(&want), "the table beside the schematic must win, got {candidates:?}" @@ -7383,7 +7401,7 @@ mod symbol_source_tests { #[test] fn a_bare_relative_schematic_keeps_an_empty_project_dir() { assert_eq!( - project_root_for(Path::new("board.kicad_sch")), + project_root_for(Path::new("board.kicad_sch")).unwrap(), Some(PathBuf::new()) ); } diff --git a/crates/konnect-core/src/tools/mod.rs b/crates/konnect-core/src/tools/mod.rs index ef5f062e..0bf7b576 100644 --- a/crates/konnect-core/src/tools/mod.rs +++ b/crates/konnect-core/src/tools/mod.rs @@ -1336,16 +1336,223 @@ pub struct SheetInstanceContext { pub is_child_sheet: bool, } +/// One structurally proven project owner of a schematic file. +/// +/// Ownership is not inferred from directory ancestry alone. A child sheet is +/// owned only when a candidate project's root schematic reaches it through +/// parsed `(sheet (property "Sheetfile" ...))` nodes. This is the authority +/// bound for the ancestor walk: it may inspect every ancestor so deeply nested +/// sheets continue to work, but an unrelated project can never win merely by +/// being higher in the filesystem (#189). +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SchematicOwnership { + pub project_file: std::path::PathBuf, + pub root_schematic: std::path::PathBuf, + pub instance_paths: Vec, +} + +/// A schematic target could not be resolved without choosing arbitrarily. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum SchematicTargetError { + AmbiguousProject { + target: std::path::PathBuf, + roots: Vec, + }, +} + +impl std::fmt::Display for SchematicTargetError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::AmbiguousProject { target, roots } => write!( + formatter, + "schematic '{}' belongs to multiple project roots: {}", + target.display(), + roots + .iter() + .map(|root| root.display().to_string()) + .collect::>() + .join(", ") + ), + } + } +} + +impl std::error::Error for SchematicTargetError {} + +impl SchematicTargetError { + pub(crate) fn into_tool_result(self) -> CallToolResult { + match self { + Self::AmbiguousProject { target, roots } => { + let candidates = roots + .iter() + .map(|root| root.display().to_string()) + .collect::>(); + CallToolResult::error_kind( + crate::mcp::error::ToolErrorKind::AmbiguousTarget { + target: target.display().to_string(), + candidates: candidates.clone(), + }, + format!( + "Schematic '{}' is reachable from multiple project roots ({}). \ + Provide a document inside one unambiguous project; Konnect did not \ + choose one or modify the schematic.", + target.display(), + candidates.join(", ") + ), + ) + } + } + } +} + +/// Resolve the unique project whose parsed sheet hierarchy owns `target`. +/// +/// A project beside a root schematic is exact. A project above a child is a +/// candidate only when its `.kicad_sch` structurally reaches the +/// target. Missing/unreadable roots and unrelated projects are ignored. If +/// more than one parsed root reaches the target, resolution refuses and names +/// every root instead of depending on `read_dir` order. +pub(crate) fn resolve_schematic_ownership( + target: &std::path::Path, +) -> Result, SchematicTargetError> { + use std::collections::BTreeSet; + + let Some(start) = target.parent() else { + return Ok(None); + }; + let mut project_files = BTreeSet::new(); + for directory in start.ancestors() { + let Ok(entries) = std::fs::read_dir(directory) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("kicad_pro")) + { + project_files.insert(path); + } + } + } + + let mut owners = Vec::new(); + for project_file in project_files { + let root_schematic = project_file.with_extension("kicad_sch"); + let Ok(root) = konnect_schematic_editor::Schematic::load(&root_schematic) else { + continue; + }; + let Some(root_uuid) = root.uuid.clone() else { + continue; + }; + + let mut instance_paths = Vec::new(); + if same_schematic_document(&root_schematic, target) { + instance_paths.push(format!("/{root_uuid}")); + } else { + let mut suffix = Vec::new(); + let mut stack = std::collections::HashSet::new(); + collect_sheet_instance_paths( + &root_schematic, + target, + &mut suffix, + &mut stack, + 0, + &root_uuid, + &mut instance_paths, + ); + } + instance_paths.sort(); + instance_paths.dedup(); + if !instance_paths.is_empty() { + owners.push(SchematicOwnership { + project_file, + root_schematic, + instance_paths, + }); + } + } + + owners.sort_by(|left, right| left.root_schematic.cmp(&right.root_schematic)); + if owners.len() > 1 { + return Err(SchematicTargetError::AmbiguousProject { + target: target.to_path_buf(), + roots: owners + .into_iter() + .map(|owner| owner.root_schematic) + .collect(), + }); + } + Ok(owners.pop()) +} + +fn collect_sheet_instance_paths( + current: &std::path::Path, + target: &std::path::Path, + suffix: &mut Vec, + stack: &mut std::collections::HashSet, + depth: usize, + root_uuid: &str, + found: &mut Vec, +) { + if depth > crate::tools::sch_hierarchy::MAX_HIERARCHY_DEPTH { + return; + } + let canonical = canonical_schematic_path(current); + if !stack.insert(canonical.clone()) { + return; + } + + if let Ok(schematic) = konnect_schematic_editor::Schematic::load(current) { + let directory = current + .parent() + .unwrap_or_else(|| std::path::Path::new(".")); + for sheet in &schematic.sheets { + let child = directory.join(sheet.file()); + suffix.push(sheet.uuid.clone()); + if same_schematic_document(&child, target) { + let mut path = format!("/{root_uuid}"); + for uuid in suffix.iter() { + path.push('/'); + path.push_str(uuid); + } + found.push(path); + } else if child.is_file() { + collect_sheet_instance_paths( + &child, + target, + suffix, + stack, + depth + 1, + root_uuid, + found, + ); + } + suffix.pop(); + } + } + + stack.remove(&canonical); +} + +fn same_schematic_document(left: &std::path::Path, right: &std::path::Path) -> bool { + canonical_schematic_path(left) == canonical_schematic_path(right) +} + +fn canonical_schematic_path(path: &std::path::Path) -> std::path::PathBuf { + path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) +} + /// Resolve `sch_path`'s place in its project. /// -/// Falls back to treating the file as its own root — the standalone-sheet -/// behaviour — whenever no project can be found, the root sheet cannot be -/// read, or the file is not reachable from it. That keeps a loose `.kicad_sch` -/// working exactly as before. -pub fn sheet_instance_context( +/// Falls back to treating the file as its own root only when no parsed project +/// hierarchy owns it. Ambiguous ownership is returned to the caller and must +/// be exposed as a structured refusal. +pub(crate) fn sheet_instance_context( sch_path: &std::path::Path, sch: &mut konnect_schematic_editor::Schematic, -) -> SheetInstanceContext { +) -> Result { let own_root = ensure_root_uuid(sch); let standalone = SheetInstanceContext { project_name: project_name_for(sch_path), @@ -1353,80 +1560,170 @@ pub fn sheet_instance_context( is_child_sheet: false, }; - let Some(project) = nearest_kicad_pro(sch_path) else { - return standalone; + let Some(ownership) = resolve_schematic_ownership(sch_path)? else { + return Ok(standalone); }; - let root_sheet = project.with_extension("kicad_sch"); - let canonical = |p: &std::path::Path| p.canonicalize().unwrap_or_else(|_| p.to_path_buf()); - if canonical(&root_sheet) == canonical(sch_path) { - // This IS the root sheet; only the project name may differ from the - // file stem, and here it cannot. - return standalone; - } - let Ok(root) = konnect_schematic_editor::Schematic::load(&root_sheet) else { - return standalone; - }; - let Some(root_uuid) = root.uuid.clone() else { - return standalone; - }; - let mut sheet_uuids = Vec::new(); - if !find_sheet_path(&root_sheet, sch_path, &mut sheet_uuids, 0) { - return standalone; - } - - let mut instance_path = format!("/{root_uuid}"); - for uuid in &sheet_uuids { - instance_path.push('/'); - instance_path.push_str(uuid); - } - SheetInstanceContext { - project_name: project + let instance_path = ownership + .instance_paths + .first() + .cloned() + .unwrap_or_else(|| standalone.instance_path.clone()); + Ok(SheetInstanceContext { + project_name: ownership + .project_file .file_stem() .and_then(|s| s.to_str()) .unwrap_or_default() .to_string(), instance_path, - is_child_sheet: true, - } -} - -/// The `.kicad_pro` governing `file`, from its own directory upwards. -fn nearest_kicad_pro(file: &std::path::Path) -> Option { - file.parent()?.ancestors().find_map(|dir| { - std::fs::read_dir(dir).ok()?.find_map(|entry| { - let path = entry.ok()?.path(); - (path.extension().and_then(|e| e.to_str()) == Some("kicad_pro")).then_some(path) - }) + is_child_sheet: !same_schematic_document(&ownership.root_schematic, sch_path), }) } -/// Depth-first walk from `from` looking for `target`, recording the uuid of -/// each `(sheet …)` node stepped through. Bounded like the hierarchy tools: -/// a `Sheetfile` cycle would otherwise recurse forever. -fn find_sheet_path( - from: &std::path::Path, - target: &std::path::Path, - acc: &mut Vec, - depth: usize, -) -> bool { - if depth > 32 { - return false; - } - let Ok(sch) = konnect_schematic_editor::Schematic::load(from) else { - return false; - }; - let dir = from.parent().unwrap_or(std::path::Path::new(".")); - let canonical = |p: &std::path::Path| p.canonicalize().unwrap_or_else(|_| p.to_path_buf()); - for sheet in sch.sheets.iter() { - let child = dir.join(sheet.file()); - acc.push(sheet.uuid.clone()); - if canonical(&child) == canonical(target) { - return true; - } - if child.exists() && find_sheet_path(&child, target, acc, depth + 1) { - return true; +#[cfg(test)] +mod schematic_target_tests { + use super::*; + use std::path::{Path, PathBuf}; + + fn write(path: &Path, content: &str) -> PathBuf { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).unwrap(); } - acc.pop(); + std::fs::write(path, content).unwrap(); + path.to_path_buf() + } + + fn blank(path: &Path) -> PathBuf { + write(path, &blank_schematic_template()) + } + + fn root_with_child(path: &Path, root_uuid: &str, child: &str, sheet_uuid: &str) -> PathBuf { + write( + path, + &format!( + r#"(kicad_sch + (version 20250610) + (generator "eeschema") + (uuid "{root_uuid}") + (paper "A4") + (lib_symbols) + (sheet + (at 20 20) + (size 40 20) + (uuid "{sheet_uuid}") + (property "Sheetname" "Child" (at 20 19.365 0)) + (property "Sheetfile" "{child}" (at 20 40.635 0)) + ) + (sheet_instances (path "/" (page "1"))) +) +"#, + ), + ) + } + + #[test] + fn exact_project_root_is_resolved() { + let directory = tempfile::tempdir().unwrap(); + write(&directory.path().join("control.kicad_pro"), "{}"); + let root = blank(&directory.path().join("control.kicad_sch")); + + let owner = resolve_schematic_ownership(&root).unwrap().unwrap(); + + assert_eq!( + owner.project_file, + directory.path().join("control.kicad_pro") + ); + assert_eq!(owner.root_schematic, root); + assert_eq!(owner.instance_paths.len(), 1); + } + + #[test] + fn deep_child_is_proven_through_the_parsed_hierarchy() { + let directory = tempfile::tempdir().unwrap(); + write(&directory.path().join("control.kicad_pro"), "{}"); + root_with_child( + &directory.path().join("control.kicad_sch"), + "root-uuid", + "sheets/mid.kicad_sch", + "mid-sheet-uuid", + ); + root_with_child( + &directory.path().join("sheets/mid.kicad_sch"), + "mid-file-uuid", + "deep/child.kicad_sch", + "child-sheet-uuid", + ); + let child = blank(&directory.path().join("sheets/deep/child.kicad_sch")); + + let owner = resolve_schematic_ownership(&child).unwrap().unwrap(); + + assert_eq!( + owner.project_file, + directory.path().join("control.kicad_pro") + ); + assert_eq!( + owner.instance_paths, + ["/root-uuid/mid-sheet-uuid/child-sheet-uuid"] + ); + } + + #[test] + fn loose_sheet_under_an_unrelated_project_stays_loose() { + let directory = tempfile::tempdir().unwrap(); + write(&directory.path().join("unrelated.kicad_pro"), "{}"); + blank(&directory.path().join("unrelated.kicad_sch")); + let loose = blank(&directory.path().join("work/loose.kicad_sch")); + + assert_eq!(resolve_schematic_ownership(&loose).unwrap(), None); + assert_eq!( + crate::tools::library::project_root_for(&loose).unwrap(), + loose.parent().map(Path::to_path_buf) + ); + } + + #[test] + fn two_structural_owners_are_a_typed_ambiguity() { + let outer = tempfile::tempdir().unwrap(); + write(&outer.path().join("outer.kicad_pro"), "{}"); + root_with_child( + &outer.path().join("outer.kicad_sch"), + "outer-root", + "nested/child.kicad_sch", + "outer-path", + ); + + let nested = outer.path().join("nested"); + write(&nested.join("inner.kicad_pro"), "{}"); + root_with_child( + &nested.join("inner.kicad_sch"), + "inner-root", + "child.kicad_sch", + "inner-path", + ); + let child = blank(&nested.join("child.kicad_sch")); + + let error = resolve_schematic_ownership(&child).unwrap_err(); + let SchematicTargetError::AmbiguousProject { target, roots } = error; + assert_eq!(target, child); + assert_eq!(roots.len(), 2); + let result = SchematicTargetError::AmbiguousProject { target, roots }.into_tool_result(); + assert_eq!( + crate::mcp::error::extract_error_kind(&result).as_deref(), + Some("ambiguous_target") + ); + } + + #[test] + fn missing_or_unreadable_candidate_root_is_not_ownership_evidence() { + let directory = tempfile::tempdir().unwrap(); + write(&directory.path().join("missing.kicad_pro"), "{}"); + write(&directory.path().join("broken.kicad_pro"), "{}"); + write( + &directory.path().join("broken.kicad_sch"), + "not a schematic", + ); + let target = blank(&directory.path().join("nested/loose.kicad_sch")); + + assert_eq!(resolve_schematic_ownership(&target).unwrap(), None); } - false } diff --git a/crates/konnect-core/src/tools/sch_batch.rs b/crates/konnect-core/src/tools/sch_batch.rs index 5dc51435..f3dc448e 100644 --- a/crates/konnect-core/src/tools/sch_batch.rs +++ b/crates/konnect-core/src/tools/sch_batch.rs @@ -482,7 +482,10 @@ async fn handle_batch_place_components( let root_uuid = crate::tools::ensure_root_uuid(&mut sch); let project_name = project_name_for(&sch_path); // Built once: the lib-table parse is memoised across the whole batch. - let src = crate::tools::library::KiCadSymbolSource::for_file(&sch_path); + let src = match crate::tools::library::KiCadSymbolSource::for_file(&sch_path) { + Ok(source) => source, + Err(error) => return Ok(error.into_tool_result()), + }; let mut placed: Vec = Vec::new(); let mut errors: Vec = Vec::new(); diff --git a/crates/konnect-core/src/tools/sch_components.rs b/crates/konnect-core/src/tools/sch_components.rs index 4e94898c..ce2da87c 100644 --- a/crates/konnect-core/src/tools/sch_components.rs +++ b/crates/konnect-core/src/tools/sch_components.rs @@ -542,9 +542,16 @@ async fn handle_add_schematic_component( // whose path doesn't resolve. On a child sheet both differ from this // file's own stem and uuid, which is what left hierarchical designs // unannotated (#204). - let context = crate::tools::sheet_instance_context(&sch_path, &mut sch); + let context = match crate::tools::sheet_instance_context(&sch_path, &mut sch) { + Ok(context) => context, + Err(error) => return Ok(error.into_tool_result()), + }; let instance_path = context.instance_path.clone(); let project_name = context.project_name.clone(); + let source = match crate::tools::library::KiCadSymbolSource::for_file(&sch_path) { + Ok(source) => source, + Err(error) => return Ok(error.into_tool_result()), + }; let result = match place_one_component( &mut sch, @@ -557,7 +564,7 @@ async fn handle_add_schematic_component( ref_str, value, unit, - &crate::tools::library::KiCadSymbolSource::for_file(&sch_path), + &source, ) { Ok(v) => v, Err(e) => return Ok(e), @@ -1754,7 +1761,10 @@ async fn handle_update_symbols_from_library( let mut unchanged = Vec::new(); let mut pins_moved = Vec::new(); let mut errors = Vec::new(); - let src = crate::tools::library::KiCadSymbolSource::for_file(&sch_path); + let src = match crate::tools::library::KiCadSymbolSource::for_file(&sch_path) { + Ok(source) => source, + Err(error) => return Ok(error.into_tool_result()), + }; let outcomes = reembed_lib_symbols(&mut content, &lib_ids, allow_pin_moves, &src); for (lib_id, outcome) in lib_ids.iter().zip(outcomes) { match outcome { @@ -1972,7 +1982,10 @@ async fn handle_replace_component( .map(|instance| instance.unit) .collect(); - let src = crate::tools::library::KiCadSymbolSource::for_file(&sch_path); + let src = match crate::tools::library::KiCadSymbolSource::for_file(&sch_path) { + Ok(source) => source, + Err(error) => return Ok(error.into_tool_result()), + }; let embedded_unit_count = parsed .find("lib_symbols") .and_then(|libraries| { @@ -2307,6 +2320,68 @@ mod tests { ); } + #[tokio::test] + async fn placement_refuses_ambiguous_project_ownership_without_writing() { + let outer = tempfile::tempdir().unwrap(); + let nested = outer.path().join("nested"); + std::fs::create_dir_all(&nested).unwrap(); + std::fs::write(outer.path().join("outer.kicad_pro"), "{}").unwrap(); + std::fs::write(nested.join("inner.kicad_pro"), "{}").unwrap(); + let root = |root_uuid: &str, sheet_uuid: &str, child: &str| { + format!( + r#"(kicad_sch + (version 20250610) + (generator "eeschema") + (uuid "{root_uuid}") + (paper "A4") + (lib_symbols) + (sheet + (at 20 20) + (size 40 20) + (uuid "{sheet_uuid}") + (property "Sheetname" "Child" (at 20 19.365 0)) + (property "Sheetfile" "{child}" (at 20 40.635 0)) + ) + (sheet_instances (path "/" (page "1"))) +) +"#, + ) + }; + std::fs::write( + outer.path().join("outer.kicad_sch"), + root("outer-root", "outer-path", "nested/child.kicad_sch"), + ) + .unwrap(); + std::fs::write( + nested.join("inner.kicad_sch"), + root("inner-root", "inner-path", "child.kicad_sch"), + ) + .unwrap(); + let child = nested.join("child.kicad_sch"); + std::fs::write(&child, crate::tools::blank_schematic_template()).unwrap(); + let before = std::fs::read(&child).unwrap(); + + let result = handle_add_schematic_component( + &json!({ + "schematic": child.display().to_string(), + "lib_id": "Device:R", + "reference": "R1", + "x": 100.0, + "y": 100.0 + }), + &test_ctx(), + ) + .await + .unwrap(); + + assert!(result.is_error); + assert_eq!( + crate::mcp::error::extract_error_kind(&result).as_deref(), + Some("ambiguous_target") + ); + assert_eq!(std::fs::read(&child).unwrap(), before); + } + /// A standalone sheet — no project file, no parent — keeps the old /// behaviour: it is its own root. #[tokio::test] diff --git a/crates/konnect-core/src/tools/sch_export.rs b/crates/konnect-core/src/tools/sch_export.rs index 85788063..d62bd7d0 100644 --- a/crates/konnect-core/src/tools/sch_export.rs +++ b/crates/konnect-core/src/tools/sch_export.rs @@ -18,7 +18,6 @@ use konnect_sexp::{ }, }; use serde_json::json; -use std::collections::HashSet; use std::path::{Path, PathBuf}; use super::cli; @@ -611,7 +610,11 @@ async fn handle_run_erc( let sch_path = get_path(args, "schematic")?; let min_severity = args["severity"].as_str().unwrap_or("warning"); - if let Some(root) = owning_project_root(&sch_path) { + let owning_root = match owning_project_root(&sch_path) { + Ok(root) => root, + Err(error) => return Ok(error.into_tool_result()), + }; + if let Some(root) = owning_root { // Structured, not free text: a caller can react to `invalid_argument` // on `schematic` by retrying against the named root, which is exactly // what the message says to do. @@ -1012,60 +1015,15 @@ mod multi_unit_connectivity_tests { /// Returns `None` for a schematic that is a root in its own right, one that /// belongs to no project, and one that sits beside a project without appearing /// in its sheet tree. -fn owning_project_root(file: &Path) -> Option { +fn owning_project_root(file: &Path) -> Result, crate::tools::SchematicTargetError> { if file.with_extension("kicad_pro").is_file() { - return None; - } - let root = project_root_schematic(&crate::tools::library::project_root_for(file)?)?; - if same_file(&root, file) { - return None; - } - let mut visited = HashSet::new(); - sheet_tree_contains(&root, file, 0, &mut visited).then_some(root) -} - -/// The `.kicad_sch` beside the single `.kicad_pro` in `dir`. A directory -/// holding more than one project says nothing definite about which root a loose -/// sheet belongs to, so it yields nothing rather than a guess. -fn project_root_schematic(dir: &Path) -> Option { - let mut found: Option = None; - for entry in std::fs::read_dir(dir).ok()?.flatten() { - let path = entry.path(); - if path.extension().is_some_and(|e| e == "kicad_pro") { - if found.is_some() { - return None; - } - found = Some(path); - } + return Ok(None); } - let sch = found?.with_extension("kicad_sch"); - sch.is_file().then_some(sch) -} - -/// Whether `target` is reachable as a sheet from `root`. Depth and visited set -/// guard the same way [`crate::tools::sch_hierarchy::build_hierarchy_node`] -/// does: a sheet may reference a file that references it back. -fn sheet_tree_contains( - root: &Path, - target: &Path, - depth: usize, - visited: &mut HashSet, -) -> bool { - if depth > crate::tools::sch_hierarchy::MAX_HIERARCHY_DEPTH { - return false; - } - let canon = canonical(root); - if !visited.insert(canon) { - return false; - } - let Ok(sch) = konnect_schematic_editor::Schematic::load(root) else { - return false; - }; - let dir = root.parent().unwrap_or_else(|| Path::new(".")); - sch.sheets.iter().any(|sheet| { - let child = dir.join(sheet.file()); - same_file(&child, target) || sheet_tree_contains(&child, target, depth + 1, visited) - }) + Ok( + crate::tools::resolve_schematic_ownership(file)?.and_then(|ownership| { + (!same_file(&ownership.root_schematic, file)).then_some(ownership.root_schematic) + }), + ) } /// Path equality that survives `.\foo` versus `foo` and case-insensitive @@ -1196,7 +1154,7 @@ mod tests { let root = root_with_child(tmp.path(), "proj.kicad_sch", "child.kicad_sch"); let child = blank(tmp.path(), "child.kicad_sch"); - assert_eq!(owning_project_root(&child), Some(root)); + assert_eq!(owning_project_root(&child).unwrap(), Some(root)); } #[test] @@ -1206,7 +1164,7 @@ mod tests { let root = root_with_child(tmp.path(), "proj.kicad_sch", "child.kicad_sch"); blank(tmp.path(), "child.kicad_sch"); - assert_eq!(owning_project_root(&root), None); + assert_eq!(owning_project_root(&root).unwrap(), None); } #[test] @@ -1214,7 +1172,7 @@ mod tests { let tmp = TempDir::new().unwrap(); let loose = blank(tmp.path(), "loose.kicad_sch"); - assert_eq!(owning_project_root(&loose), None); + assert_eq!(owning_project_root(&loose).unwrap(), None); } /// The refusal is a structured `invalid_argument` naming `schematic`, so a @@ -1270,7 +1228,7 @@ mod tests { blank(tmp.path(), "child.kicad_sch"); let stranger = blank(tmp.path(), "stranger.kicad_sch"); - assert_eq!(owning_project_root(&stranger), None); + assert_eq!(owning_project_root(&stranger).unwrap(), None); } /// A sheet cycle must not hang the walk. @@ -1282,6 +1240,6 @@ mod tests { root_with_child(tmp.path(), "a.kicad_sch", "proj.kicad_sch"); let stranger = blank(tmp.path(), "stranger.kicad_sch"); - assert_eq!(owning_project_root(&stranger), None); + assert_eq!(owning_project_root(&stranger).unwrap(), None); } } diff --git a/crates/konnect-core/src/tools/sch_wiring.rs b/crates/konnect-core/src/tools/sch_wiring.rs index 7c578658..43294461 100644 --- a/crates/konnect-core/src/tools/sch_wiring.rs +++ b/crates/konnect-core/src/tools/sch_wiring.rs @@ -1592,7 +1592,10 @@ async fn handle_add_power_symbol( // Embed the power symbol definition in lib_symbols let lib_id = format!("power:{}", power_net); - let src = crate::tools::library::KiCadSymbolSource::for_file(&sch_path); + let src = match crate::tools::library::KiCadSymbolSource::for_file(&sch_path) { + Ok(source) => source, + Err(error) => return Ok(error.into_tool_result()), + }; if !cse::library::ensure_lib_symbol(&mut sch, &lib_id, &src) { return Ok(crate::tools::lib_symbol_not_found_error(&lib_id, &src)); } From b4caa1e367005f64fd8c543829abc09fe5fa52a4 Mon Sep 17 00:00:00 2001 From: dubesinhower Date: Sun, 30 Aug 2026 09:40:10 -0400 Subject: [PATCH 10/16] fix(schematic): bind placement to all sheet instances --- crates/konnect-core/src/mcp/error.rs | 8 + crates/konnect-core/src/tools/mod.rs | 91 ++++++- crates/konnect-core/src/tools/sch_batch.rs | 105 +++++++- .../konnect-core/src/tools/sch_components.rs | 227 ++++++++++++++++-- crates/konnect-core/src/tools/sch_wiring.rs | 40 +-- .../src/schematic/symbol.rs | 57 ++++- 6 files changed, 478 insertions(+), 50 deletions(-) diff --git a/crates/konnect-core/src/mcp/error.rs b/crates/konnect-core/src/mcp/error.rs index d0c54095..4a48beab 100644 --- a/crates/konnect-core/src/mcp/error.rs +++ b/crates/konnect-core/src/mcp/error.rs @@ -57,6 +57,9 @@ pub enum ToolErrorKind { target: String, candidates: Vec, }, + /// The caller named a target, but its observed editor or document state + /// no longer agrees with the state required to mutate it safely. + StaleTarget { target: String, reason: String }, /// A board was live earlier in this server process, but IPC is now gone; /// its saved file may be stale relative to lost editor state. UnsafeFileFallback { path: String }, @@ -77,6 +80,7 @@ impl ToolErrorKind { Self::FileNotFound { .. } => "file_not_found", Self::Conflict { .. } => "conflict", Self::AmbiguousTarget { .. } => "ambiguous_target", + Self::StaleTarget { .. } => "stale_target", Self::UnsafeFileFallback { .. } => "unsafe_file_fallback", Self::HandlerError { .. } => "handler_error", } @@ -181,6 +185,10 @@ mod tests { target: "p".into(), candidates: vec!["a".into(), "b".into()], }, + ToolErrorKind::StaleTarget { + target: "p".into(), + reason: "r".into(), + }, ToolErrorKind::UnsafeFileFallback { path: "p".into() }, ToolErrorKind::HandlerError { reason: "r".into() }, ]; diff --git a/crates/konnect-core/src/tools/mod.rs b/crates/konnect-core/src/tools/mod.rs index 0bf7b576..1009910f 100644 --- a/crates/konnect-core/src/tools/mod.rs +++ b/crates/konnect-core/src/tools/mod.rs @@ -1332,6 +1332,10 @@ pub struct SheetInstanceContext { pub project_name: String, /// `/root-uuid[/sheet-uuid…]`, the path from the root down to this sheet. pub instance_path: String, + /// Every structurally observed path to this document. A reused child sheet + /// has one entry per hierarchy instance; document-wide edits affect all of + /// them and must never silently choose the first. + pub instance_paths: Vec, /// Whether this sheet was reached from a root other than itself. pub is_child_sheet: bool, } @@ -1358,6 +1362,10 @@ pub(crate) enum SchematicTargetError { target: std::path::PathBuf, roots: Vec, }, + StaleTarget { + target: std::path::PathBuf, + reason: String, + }, } impl std::fmt::Display for SchematicTargetError { @@ -1373,6 +1381,13 @@ impl std::fmt::Display for SchematicTargetError { .collect::>() .join(", ") ), + Self::StaleTarget { target, reason } => { + write!( + formatter, + "schematic '{}' is stale: {reason}", + target.display() + ) + } } } } @@ -1401,6 +1416,18 @@ impl SchematicTargetError { ), ) } + Self::StaleTarget { target, reason } => CallToolResult::error_kind( + crate::mcp::error::ToolErrorKind::StaleTarget { + target: target.display().to_string(), + reason: reason.clone(), + }, + format!( + "Schematic '{}' does not match its structurally observed target state: {}. \ + Konnect did not modify the schematic.", + target.display(), + reason + ), + ), } } } @@ -1557,6 +1584,7 @@ pub(crate) fn sheet_instance_context( let standalone = SheetInstanceContext { project_name: project_name_for(sch_path), instance_path: format!("/{own_root}"), + instance_paths: vec![format!("/{own_root}")], is_child_sheet: false, }; @@ -1576,10 +1604,69 @@ pub(crate) fn sheet_instance_context( .unwrap_or_default() .to_string(), instance_path, + instance_paths: ownership.instance_paths, is_child_sheet: !same_schematic_document(&ownership.root_schematic, sch_path), }) } +/// Prove that every existing placed symbol is keyed to exactly the hierarchy +/// identities observed from the parsed project root. +/// +/// A missing, foreign, duplicate, or obsolete path means the file's saved +/// instance metadata is stale. Adding another symbol in that state would +/// produce a document where KiCad resolves different components against +/// different hierarchy instances, so mutation fails closed before the in-memory +/// schematic is changed. +pub(crate) fn validate_sheet_instance_state( + sch_path: &std::path::Path, + schematic: &konnect_schematic_editor::Schematic, + context: &SheetInstanceContext, +) -> Result<(), SchematicTargetError> { + let mut expected = context + .instance_paths + .iter() + .map(|path| (context.project_name.clone(), path.clone())) + .collect::>(); + expected.sort(); + + let mut stale_symbols = Vec::new(); + for symbol in &schematic.symbols { + let mut observed = symbol.instance_paths(); + observed.sort(); + if observed != expected { + let identity = symbol + .reference() + .filter(|reference| !reference.is_empty()) + .unwrap_or(symbol.uuid.as_str()); + let format_paths = |paths: &[(String, String)]| { + paths + .iter() + .map(|(project, path)| format!("{project}:{path}")) + .collect::>() + .join(", ") + }; + stale_symbols.push(format!( + "{identity} observed [{}], expected [{}]", + format_paths(&observed), + format_paths(&expected) + )); + } + } + + if stale_symbols.is_empty() { + Ok(()) + } else { + Err(SchematicTargetError::StaleTarget { + target: sch_path.to_path_buf(), + reason: format!( + "placed-symbol instance metadata disagrees with project '{}': {}", + context.project_name, + stale_symbols.join("; ") + ), + }) + } +} + #[cfg(test)] mod schematic_target_tests { use super::*; @@ -1703,7 +1790,9 @@ mod schematic_target_tests { let child = blank(&nested.join("child.kicad_sch")); let error = resolve_schematic_ownership(&child).unwrap_err(); - let SchematicTargetError::AmbiguousProject { target, roots } = error; + let SchematicTargetError::AmbiguousProject { target, roots } = error else { + panic!("expected ambiguous project error") + }; assert_eq!(target, child); assert_eq!(roots.len(), 2); let result = SchematicTargetError::AmbiguousProject { target, roots }.into_tool_result(); diff --git a/crates/konnect-core/src/tools/sch_batch.rs b/crates/konnect-core/src/tools/sch_batch.rs index f3dc448e..9351965f 100644 --- a/crates/konnect-core/src/tools/sch_batch.rs +++ b/crates/konnect-core/src/tools/sch_batch.rs @@ -8,8 +8,8 @@ use crate::mcp::protocol::CallToolResult; use crate::tool; use crate::tools::{ - find_all_symbol_instance_blocks, get_path, opt_str, project_name_for, require_array, - require_f64, require_str, ToolDef, + find_all_symbol_instance_blocks, get_path, opt_str, require_array, require_f64, require_str, + ToolDef, }; use konnect_schematic_editor as cse; use konnect_sexp::{ @@ -29,7 +29,7 @@ use std::collections::HashSet; use super::sch_connectivity::{ConnectivityIndex, COINCIDENT_TOLERANCE}; // Re-use the single-item component placer and pin-to-pin router. -use super::sch_components::place_one_component; +use super::sch_components::{place_one_component, placed_component_readback}; use super::sch_wiring::{resolve_pin_endpoint, resolve_placed_pin, route_between}; // ─── Tool definitions ───────────────────────────────────────────────────────── @@ -479,15 +479,20 @@ async fn handle_batch_place_components( }; let mut sch = cse::Schematic::load(&sch_path)?; - let root_uuid = crate::tools::ensure_root_uuid(&mut sch); - let project_name = project_name_for(&sch_path); + let context = match crate::tools::sheet_instance_context(&sch_path, &mut sch) { + Ok(context) => context, + Err(error) => return Ok(error.into_tool_result()), + }; + if let Err(error) = crate::tools::validate_sheet_instance_state(&sch_path, &sch, &context) { + return Ok(error.into_tool_result()); + } // Built once: the lib-table parse is memoised across the whole batch. let src = match crate::tools::library::KiCadSymbolSource::for_file(&sch_path) { Ok(source) => source, Err(error) => return Ok(error.into_tool_result()), }; - let mut placed: Vec = Vec::new(); + let mut placed_uuids = Vec::new(); let mut errors: Vec = Vec::new(); for comp in &components { @@ -506,8 +511,8 @@ async fn handle_batch_place_components( match place_one_component( &mut sch, - &root_uuid, - &project_name, + &context.instance_paths, + &context.project_name, lib_id, x, y, @@ -517,13 +522,21 @@ async fn handle_batch_place_components( unit, &src, ) { - Ok(v) => placed.push(v), + Ok(uuid) => placed_uuids.push(uuid), Err(e) => errors.push(error_text(&e)), } } - if !placed.is_empty() { + let mut placed = Vec::new(); + if !placed_uuids.is_empty() { sch.overwrite()?; + let committed = cse::Schematic::load(&sch_path)?; + for uuid in &placed_uuids { + match placed_component_readback(&sch_path, &committed, uuid, &context) { + Ok(result) => placed.push(result), + Err(error) => return Ok(error), + } + } } let mut result = CallToolResult::json(&json!({ @@ -1657,6 +1670,78 @@ mod batch_place_and_connect_tests { ); } + #[tokio::test] + async fn batch_placement_uses_all_reused_child_paths_and_observed_results() { + let (directory, child) = seeded_schematic(); + std::fs::write(directory.path().join("board.kicad_pro"), "{}").unwrap(); + let root = directory.path().join("board.kicad_sch"); + std::fs::write( + &root, + r#"(kicad_sch + (version 20250610) + (generator "eeschema") + (uuid "ROOTUUID") + (paper "A4") + (lib_symbols) + (sheet + (at 20 20) + (size 40 20) + (uuid "SHEET-A") + (property "Sheetname" "First" (at 20 19.365 0)) + (property "Sheetfile" "place.kicad_sch" (at 20 40.635 0)) + ) + (sheet + (at 80 20) + (size 40 20) + (uuid "SHEET-B") + (property "Sheetname" "Second" (at 80 19.365 0)) + (property "Sheetfile" "place.kicad_sch" (at 80 40.635 0)) + ) + (sheet_instances (path "/" (page "1"))) +) +"#, + ) + .unwrap(); + let root_before = std::fs::read(&root).unwrap(); + + let result = handle_batch_place_components( + &json!({ + "schematic": child.display().to_string(), + "components": [ + { "lib_id": "Device:R", "x": 100.1, "y": 100.2, "reference": "R1" }, + { "lib_id": "Device:R", "x": 110.1, "y": 100.2, "reference": "R2" } + ] + }), + &test_ctx(), + ) + .await + .unwrap(); + assert!(!result.is_error, "{result:?}"); + + let body = match &result.content[0] { + crate::mcp::protocol::ToolContent::Text { text } => text, + other => panic!("expected text, got {other:?}"), + }; + let response: serde_json::Value = serde_json::from_str(body).unwrap(); + assert_eq!(response["placed_count"], 2); + for placed in response["placed"].as_array().unwrap() { + assert_eq!( + placed["instance_paths"], + json!(["/ROOTUUID/SHEET-A", "/ROOTUUID/SHEET-B"]) + ); + let uuid = placed["uuid"].as_str().unwrap(); + let committed = cse::Schematic::load(&child).unwrap(); + let symbol = committed + .symbols + .iter() + .find(|symbol| symbol.uuid == uuid) + .unwrap(); + assert_eq!(placed["x"].as_f64(), Some(symbol.at.x)); + assert_eq!(placed["y"].as_f64(), Some(symbol.at.y)); + } + assert_eq!(std::fs::read(&root).unwrap(), root_before); + } + #[tokio::test] async fn batch_place_components_collects_per_item_errors() { let (_d, path) = seeded_schematic(); diff --git a/crates/konnect-core/src/tools/sch_components.rs b/crates/konnect-core/src/tools/sch_components.rs index ce2da87c..a09f5918 100644 --- a/crates/konnect-core/src/tools/sch_components.rs +++ b/crates/konnect-core/src/tools/sch_components.rs @@ -546,17 +546,18 @@ async fn handle_add_schematic_component( Ok(context) => context, Err(error) => return Ok(error.into_tool_result()), }; - let instance_path = context.instance_path.clone(); - let project_name = context.project_name.clone(); + if let Err(error) = crate::tools::validate_sheet_instance_state(&sch_path, &sch, &context) { + return Ok(error.into_tool_result()); + } let source = match crate::tools::library::KiCadSymbolSource::for_file(&sch_path) { Ok(source) => source, Err(error) => return Ok(error.into_tool_result()), }; - let result = match place_one_component( + let uuid = match place_one_component( &mut sch, - &instance_path, - &project_name, + &context.instance_paths, + &context.project_name, &lib_id, x, y, @@ -566,7 +567,7 @@ async fn handle_add_schematic_component( unit, &source, ) { - Ok(v) => v, + Ok(uuid) => uuid, Err(e) => return Ok(e), }; @@ -576,8 +577,12 @@ async fn handle_add_schematic_component( // KiCad's netlister treats it as unconnected. Runs after the write because // it re-reads the saved file; `place_one_component` stays pure so the batch // path can do one junction pass for the whole batch instead of one per part. - let mut result = result; let junctions = crate::tools::add_pin_midwire_junctions(&sch_path, ref_str)?; + let committed = cse::Schematic::load(&sch_path)?; + let mut result = match placed_component_readback(&sch_path, &committed, &uuid, &context) { + Ok(result) => result, + Err(error) => return Ok(error), + }; result["junctions_added"] = json!(junctions .iter() .map(|(x, y)| json!({ "x": x, "y": y })) @@ -592,7 +597,7 @@ async fn handle_add_schematic_component( #[allow(clippy::too_many_arguments)] pub(crate) fn place_one_component( sch: &mut cse::Schematic, - instance_path: &str, + instance_paths: &[String], project_name: &str, lib_id: &str, x: f64, @@ -602,7 +607,7 @@ pub(crate) fn place_one_component( value: Option<&str>, unit: u32, src: &dyn cse::library::SymbolLibrarySource, -) -> Result { +) -> Result { // Snap to 1.27mm grid let (x, y) = snap_point(x, y, 1.27); let val_str = value.unwrap_or(lib_id.split(':').next_back().unwrap_or("?")); @@ -693,18 +698,74 @@ pub(crate) fn place_one_component( // Instance entry, keyed to the root sheet UUID like eeschema writes it: // (instances (project "" (path "/" (reference ...) (unit 1)))) - sym.set_instance_path(project_name, instance_path, reference, unit); + for instance_path in instance_paths { + sym.set_instance_path(project_name, instance_path, reference, unit); + } let uuid = sym.uuid.clone(); sch.add_symbol(sym); + Ok(uuid) +} + +/// Build a placement response only from the committed schematic that was read +/// back after the write. The UUID is the mutation's stable identity; requested +/// coordinates, fields, and hierarchy paths are never echoed as proof. +pub(crate) fn placed_component_readback( + sch_path: &std::path::Path, + committed: &cse::Schematic, + uuid: &str, + context: &crate::tools::SheetInstanceContext, +) -> Result { + if let Err(error) = crate::tools::validate_sheet_instance_state(sch_path, committed, context) { + return Err(error.into_tool_result()); + } + let Some(symbol) = committed.symbols.iter().find(|symbol| symbol.uuid == uuid) else { + return Err(crate::tools::SchematicTargetError::StaleTarget { + target: sch_path.to_path_buf(), + reason: format!("placed symbol UUID '{uuid}' is absent from post-write readback"), + } + .into_tool_result()); + }; + let Some(reference) = symbol.reference() else { + return Err(crate::tools::SchematicTargetError::StaleTarget { + target: sch_path.to_path_buf(), + reason: format!( + "placed symbol UUID '{}' has no Reference in post-write readback", + symbol.uuid + ), + } + .into_tool_result()); + }; + let Some(value) = symbol.value_str() else { + return Err(crate::tools::SchematicTargetError::StaleTarget { + target: sch_path.to_path_buf(), + reason: format!( + "placed symbol UUID '{}' has no Value in post-write readback", + symbol.uuid + ), + } + .into_tool_result()); + }; + let mut instance_paths = symbol + .instance_paths() + .into_iter() + .filter_map(|(project, path)| (project == context.project_name).then_some(path)) + .collect::>(); + instance_paths.sort(); + Ok(json!({ - "added": lib_id, + "schematic": sch_path.display().to_string(), + "added": symbol.lib_id, "reference": reference, - "value": val_str, - "x": x, "y": y, - "unit": unit, - "uuid": uuid + "value": value, + "x": symbol.at.x, + "y": symbol.at.y, + "rotation": symbol.at.rotation.unwrap_or(0.0), + "unit": symbol.unit, + "uuid": symbol.uuid, + "project": context.project_name, + "instance_paths": instance_paths })) } @@ -2320,6 +2381,142 @@ mod tests { ); } + #[tokio::test] + async fn reused_child_placement_writes_and_reports_every_observed_instance_path() { + let (dir, _env) = stub_symbol_dir(); + let root = dir.path().join("board.kicad_sch"); + let child = dir.path().join("shared.kicad_sch"); + std::fs::write(dir.path().join("board.kicad_pro"), "{}").unwrap(); + std::fs::write( + &root, + r#"(kicad_sch + (version 20250610) + (generator "eeschema") + (uuid "ROOTUUID") + (paper "A4") + (lib_symbols) + (sheet + (at 20 20) + (size 40 20) + (uuid "SHEET-A") + (property "Sheetname" "First" (at 20 19.365 0)) + (property "Sheetfile" "shared.kicad_sch" (at 20 40.635 0)) + ) + (sheet + (at 80 20) + (size 40 20) + (uuid "SHEET-B") + (property "Sheetname" "Second" (at 80 19.365 0)) + (property "Sheetfile" "shared.kicad_sch" (at 80 40.635 0)) + ) + (sheet_instances (path "/" (page "1"))) +) +"#, + ) + .unwrap(); + std::fs::write(&child, crate::tools::blank_schematic_template()).unwrap(); + let root_before = std::fs::read(&root).unwrap(); + + let result = handle_add_schematic_component( + &json!({ + "schematic": child.display().to_string(), + "lib_id": "Device:R", + "reference": "R1", + "value": "4k7", + "x": 100.1, + "y": 80.2 + }), + &test_ctx(), + ) + .await + .unwrap(); + assert!(!result.is_error, "{result:?}"); + + let response: serde_json::Value = serde_json::from_str(&content_text(&result)).unwrap(); + assert_eq!( + response["instance_paths"], + json!(["/ROOTUUID/SHEET-A", "/ROOTUUID/SHEET-B"]) + ); + assert_eq!(response["schematic"], child.display().to_string()); + assert_eq!(response["project"], "board"); + + let committed = cse::Schematic::load(&child).unwrap(); + let symbol = committed.symbols.by_reference("R1").unwrap(); + let mut observed = symbol + .instance_paths() + .into_iter() + .filter_map(|(project, path)| (project == "board").then_some(path)) + .collect::>(); + observed.sort(); + assert_eq!(observed, ["/ROOTUUID/SHEET-A", "/ROOTUUID/SHEET-B"]); + assert_eq!(response["uuid"], symbol.uuid); + assert_eq!(response["x"].as_f64(), Some(symbol.at.x)); + assert_eq!(response["y"].as_f64(), Some(symbol.at.y)); + assert_eq!( + std::fs::read(&root).unwrap(), + root_before, + "the explicitly targeted child edit must not rewrite the root document" + ); + } + + #[tokio::test] + async fn stale_child_instance_metadata_is_refused_without_writing() { + let (dir, _env) = stub_symbol_dir(); + let root = dir.path().join("board.kicad_sch"); + let child = dir.path().join("child.kicad_sch"); + std::fs::write(dir.path().join("board.kicad_pro"), "{}").unwrap(); + std::fs::write( + &root, + r#"(kicad_sch + (version 20250610) + (generator "eeschema") + (uuid "ROOTUUID") + (paper "A4") + (lib_symbols) + (sheet + (at 20 20) + (size 40 20) + (uuid "CURRENT-SHEET") + (property "Sheetname" "Child" (at 20 19.365 0)) + (property "Sheetfile" "child.kicad_sch" (at 20 40.635 0)) + ) + (sheet_instances (path "/" (page "1"))) +) +"#, + ) + .unwrap(); + std::fs::write(&child, crate::tools::blank_schematic_template()).unwrap(); + let mut stale = cse::Schematic::load(&child).unwrap(); + let mut existing = cse::Symbol::new("Device:R", 50.0, 50.0); + existing.set_reference("R0"); + existing.set_value_str("1k"); + existing.set_instance_path("board", "/ROOTUUID/OLD-SHEET", "R0", 1); + stale.add_symbol(existing); + stale.overwrite().unwrap(); + let before = std::fs::read(&child).unwrap(); + + let result = handle_add_schematic_component( + &json!({ + "schematic": child.display().to_string(), + "lib_id": "Device:R", + "reference": "R1", + "x": 100.0, + "y": 80.0 + }), + &test_ctx(), + ) + .await + .unwrap(); + + assert!(result.is_error); + assert_eq!( + crate::mcp::error::extract_error_kind(&result).as_deref(), + Some("stale_target") + ); + assert!(content_text(&result).contains("R0")); + assert_eq!(std::fs::read(&child).unwrap(), before); + } + #[tokio::test] async fn placement_refuses_ambiguous_project_ownership_without_writing() { let outer = tempfile::tempdir().unwrap(); diff --git a/crates/konnect-core/src/tools/sch_wiring.rs b/crates/konnect-core/src/tools/sch_wiring.rs index 43294461..3313e2f4 100644 --- a/crates/konnect-core/src/tools/sch_wiring.rs +++ b/crates/konnect-core/src/tools/sch_wiring.rs @@ -6,8 +6,7 @@ use crate::mcp::protocol::CallToolResult; use crate::tool; use crate::tools::{ - get_path, opt_f64, opt_str, project_name_for, require_array, require_f64, require_str, - ToolContext, ToolDef, + get_path, opt_f64, opt_str, require_array, require_f64, require_str, ToolContext, ToolDef, }; use konnect_schematic_editor as cse; use konnect_sexp::{ @@ -1587,6 +1586,13 @@ async fn handle_add_power_symbol( let rotation = opt_f64(args, "rotation").unwrap_or(0.0); let mut sch = cse::Schematic::load(&sch_path)?; + let context = match crate::tools::sheet_instance_context(&sch_path, &mut sch) { + Ok(context) => context, + Err(error) => return Ok(error.into_tool_result()), + }; + if let Err(error) = crate::tools::validate_sheet_instance_state(&sch_path, &sch, &context) { + return Ok(error.into_tool_result()); + } let pwr_ref = format!("#PWR{:03}", next_pwr_number(&sch)); @@ -1673,27 +1679,31 @@ async fn handle_add_power_symbol( // Instance entry, keyed to the root sheet UUID like eeschema writes it — // without a resolvable "/" path KiCAD's netlister drops the // symbol from net formation. - let root_uuid = crate::tools::ensure_root_uuid(&mut sch); - sym.set_instance_path( - &project_name_for(&sch_path), - &format!("/{}", root_uuid), - &pwr_ref, - 1, - ); + for instance_path in &context.instance_paths { + sym.set_instance_path(&context.project_name, instance_path, &pwr_ref, 1); + } + let uuid = sym.uuid.clone(); sch.add_symbol(sym); sch.overwrite()?; // A power pin landing mid-segment on an existing wire needs a junction // dot, or KiCad ERC reports it as not connected. let junctions_added = crate::tools::add_pin_midwire_junctions(&sch_path, &pwr_ref)?; + let committed = cse::Schematic::load(&sch_path)?; + let mut observed = match super::sch_components::placed_component_readback( + &sch_path, &committed, &uuid, &context, + ) { + Ok(result) => result, + Err(error) => return Ok(error), + }; + observed["added_power"] = observed["value"].clone(); + observed["junctions_added"] = json!(junctions_added + .iter() + .map(|(x, y)| json!({"x": x, "y": y})) + .collect::>()); - Ok(CallToolResult::json(&json!({ - "added_power": power_net, - "reference": pwr_ref, - "x": x, "y": y, - "junctions_added": junctions_added.iter().map(|(x, y)| json!({"x": x, "y": y})).collect::>() - }))) + Ok(CallToolResult::json(&observed)) } async fn handle_add_no_connect( diff --git a/crates/konnect-schematic-editor/src/schematic/symbol.rs b/crates/konnect-schematic-editor/src/schematic/symbol.rs index 6436db18..70bc66c2 100644 --- a/crates/konnect-schematic-editor/src/schematic/symbol.rs +++ b/crates/konnect-schematic-editor/src/schematic/symbol.rs @@ -286,16 +286,36 @@ impl Symbol { /// Whether this symbol already has an instance entry for the given /// project name and hierarchical path. pub fn has_instance_path(&self, project_name: &str, path: &str) -> bool { - self.raw_sub_nodes + self.instance_paths() .iter() - .find(|n| n.tag() == Some("instances")) - .map(|inst| { - inst.find_all("project").iter().any(|p| { - p.value() == Some(project_name) - && p.find_all("path").iter().any(|pp| pp.value() == Some(path)) - }) - }) - .unwrap_or(false) + .any(|(project, candidate)| project == project_name && candidate == path) + } + + /// Every project/path identity carried by this placed symbol. + /// + /// A child schematic file can be instantiated more than once in one root + /// hierarchy, so one placed symbol legitimately carries multiple paths. + /// Returning all of them lets callers compare the saved identity with the + /// structurally observed hierarchy instead of selecting the first entry. + pub fn instance_paths(&self) -> Vec<(String, String)> { + let mut paths = Vec::new(); + for instances in self + .raw_sub_nodes + .iter() + .filter(|node| node.tag() == Some("instances")) + { + for project in instances.find_all("project") { + let Some(project_name) = project.value() else { + continue; + }; + for path in project.find_all("path") { + if let Some(path_value) = path.value() { + paths.push((project_name.to_string(), path_value.to_string())); + } + } + } + } + paths } // ---- position ----------------------------------------------------------- @@ -528,4 +548,23 @@ mod tests { ); assert_eq!((sym.at.x, sym.at.y), (110.0, 60.0)); } + + #[test] + fn instance_paths_reports_every_reused_hierarchy_identity() { + let mut symbol = Symbol::new("Device:R", 100.0, 50.0); + symbol.set_instance_path("control", "/root/a", "R1", 1); + symbol.set_instance_path("control", "/root/b", "R1", 1); + symbol.set_instance_path("other", "/other/c", "R1", 1); + + assert_eq!( + symbol.instance_paths(), + [ + ("control".to_string(), "/root/a".to_string()), + ("control".to_string(), "/root/b".to_string()), + ("other".to_string(), "/other/c".to_string()), + ] + ); + assert!(symbol.has_instance_path("control", "/root/b")); + assert!(!symbol.has_instance_path("control", "/root/missing")); + } } From c3ff1aa2e46e92c924b79dab9b2a53273d856e15 Mon Sep 17 00:00:00 2001 From: dubesinhower Date: Sun, 30 Aug 2026 09:50:32 -0400 Subject: [PATCH 11/16] fix(ipc): bind operations to the requested board --- crates/konnect-core/src/mcp/error.rs | 11 + crates/konnect-core/src/tools/mod.rs | 105 +++++++++ crates/konnect-core/src/tools/pcb_board.rs | 47 +++- .../konnect-core/src/tools/pcb_components.rs | 6 + crates/konnect-core/src/tools/pcb_routing.rs | 3 + crates/konnect-ipc/src/client.rs | 216 ++++++++++++++++-- crates/konnect-ipc/src/lib.rs | 2 +- crates/konnect-ipc/tests/mock_server_test.rs | 191 ++++++++++++++++ 8 files changed, 555 insertions(+), 26 deletions(-) diff --git a/crates/konnect-core/src/mcp/error.rs b/crates/konnect-core/src/mcp/error.rs index 4a48beab..272ddcea 100644 --- a/crates/konnect-core/src/mcp/error.rs +++ b/crates/konnect-core/src/mcp/error.rs @@ -57,6 +57,12 @@ pub enum ToolErrorKind { target: String, candidates: Vec, }, + /// The requested document is not the document set observed in the target + /// editor, so proceeding would answer about or mutate another file. + WrongDocument { + requested: String, + open_documents: Vec, + }, /// The caller named a target, but its observed editor or document state /// no longer agrees with the state required to mutate it safely. StaleTarget { target: String, reason: String }, @@ -80,6 +86,7 @@ impl ToolErrorKind { Self::FileNotFound { .. } => "file_not_found", Self::Conflict { .. } => "conflict", Self::AmbiguousTarget { .. } => "ambiguous_target", + Self::WrongDocument { .. } => "wrong_document", Self::StaleTarget { .. } => "stale_target", Self::UnsafeFileFallback { .. } => "unsafe_file_fallback", Self::HandlerError { .. } => "handler_error", @@ -185,6 +192,10 @@ mod tests { target: "p".into(), candidates: vec!["a".into(), "b".into()], }, + ToolErrorKind::WrongDocument { + requested: "p".into(), + open_documents: vec!["a".into()], + }, ToolErrorKind::StaleTarget { target: "p".into(), reason: "r".into(), diff --git a/crates/konnect-core/src/tools/mod.rs b/crates/konnect-core/src/tools/mod.rs index 1009910f..0e5fbe14 100644 --- a/crates/konnect-core/src/tools/mod.rs +++ b/crates/konnect-core/src/tools/mod.rs @@ -348,6 +348,111 @@ where .await } +/// Convert the IPC layer's typed board-target refusal into the stable MCP +/// taxonomy. No handler should flatten these into "KiCad must be running" or +/// a generic rejection: callers need to distinguish a wrong document from an +/// ambiguous or stale one. +pub(crate) fn ipc_target_error_result(error: &konnect_ipc::BoardTargetError) -> CallToolResult { + use konnect_ipc::BoardTargetError; + + let message = format!("{}. Konnect did not read or modify another board.", error); + match error { + BoardTargetError::NoOpenDocuments { requested } => CallToolResult::error_kind( + crate::mcp::error::ToolErrorKind::WrongDocument { + requested: requested.clone(), + open_documents: Vec::new(), + }, + message, + ), + BoardTargetError::WrongDocument { + requested, + open_documents, + } => CallToolResult::error_kind( + crate::mcp::error::ToolErrorKind::WrongDocument { + requested: requested.clone(), + open_documents: open_documents.clone(), + }, + message, + ), + BoardTargetError::AmbiguousDocument { + requested, + candidates, + } => CallToolResult::error_kind( + crate::mcp::error::ToolErrorKind::AmbiguousTarget { + target: requested.clone(), + candidates: candidates.clone(), + }, + message, + ), + BoardTargetError::StaleDocument { + requested, + previously_bound, + open_documents, + } => CallToolResult::error_kind( + crate::mcp::error::ToolErrorKind::StaleTarget { + target: requested.clone(), + reason: format!( + "previously bound document '{}' is no longer uniquely open; observed [{}]", + previously_bound, + open_documents.join(", ") + ), + }, + message, + ), + } +} + +#[cfg(test)] +mod ipc_target_error_tests { + use super::*; + + #[test] + fn ipc_document_target_failures_keep_distinct_structured_kinds() { + let cases = [ + ( + konnect_ipc::BoardTargetError::NoOpenDocuments { + requested: "target.kicad_pcb".to_string(), + }, + "wrong_document", + ), + ( + konnect_ipc::BoardTargetError::WrongDocument { + requested: "target.kicad_pcb".to_string(), + open_documents: vec!["other.kicad_pcb".to_string()], + }, + "wrong_document", + ), + ( + konnect_ipc::BoardTargetError::AmbiguousDocument { + requested: "target.kicad_pcb".to_string(), + candidates: vec![ + "target.kicad_pcb".to_string(), + "target.kicad_pcb".to_string(), + ], + }, + "ambiguous_target", + ), + ( + konnect_ipc::BoardTargetError::StaleDocument { + requested: "target.kicad_pcb".to_string(), + previously_bound: "target.kicad_pcb".to_string(), + open_documents: vec!["other.kicad_pcb".to_string()], + }, + "stale_target", + ), + ]; + + for (error, expected_kind) in cases { + let result = ipc_target_error_result(&error); + assert!(result.is_error); + assert_eq!( + crate::mcp::error::extract_error_kind(&result).as_deref(), + Some(expected_kind) + ); + } + } +} + // ─── Argument helpers ───────────────────────────────────────────────────────── /// Build a structured `InvalidArgument` CallToolResult. Used by the diff --git a/crates/konnect-core/src/tools/pcb_board.rs b/crates/konnect-core/src/tools/pcb_board.rs index 056b397f..0a9ba498 100644 --- a/crates/konnect-core/src/tools/pcb_board.rs +++ b/crates/konnect-core/src/tools/pcb_board.rs @@ -229,6 +229,9 @@ where board open, so editing the file directly could be silently overwritten." )))) } + Err(konnect_ipc::IpcFailure::Target { error, .. }) => Ok(BoardWrite::Refused( + crate::tools::ipc_target_error_result(&error), + )), Err(konnect_ipc::IpcFailure::Unreachable(_)) => { if ctx.board_session.was_observed_live(board_path) { Ok(BoardWrite::Refused(unsafe_file_fallback(board_path))) @@ -271,7 +274,16 @@ pub(crate) async fn refuse_if_board_open_in_kicad( be discarded by KiCAD's next save. Close the board in KiCAD (or make the edit \ there) and retry — this tool has no IPC path for a live board yet." )))), - Err(konnect_ipc::IpcFailure::Rejected(_)) => Ok(None), + Err(konnect_ipc::IpcFailure::Target { + error: + konnect_ipc::BoardTargetError::NoOpenDocuments { .. } + | konnect_ipc::BoardTargetError::WrongDocument { .. }, + .. + }) + | Err(konnect_ipc::IpcFailure::Rejected(_)) => Ok(None), + Err(konnect_ipc::IpcFailure::Target { error, .. }) => { + Ok(Some(crate::tools::ipc_target_error_result(&error))) + } Err(konnect_ipc::IpcFailure::Unreachable(_)) => Ok(ctx .board_session .was_observed_live(board_path) @@ -2244,7 +2256,7 @@ mod board_mock { #[cfg(test)] mod board_session_safety_tests { - use super::board_mock::ctx_talking_to; + use super::board_mock::{ctx_talking_to, spawn_kicad_holding_board}; use super::*; use konnect_ipc::gen::kiapi; use prost::Message; @@ -2360,6 +2372,37 @@ mod board_session_safety_tests { assert!(matches!(outcome, BoardWrite::File)); } + #[tokio::test] + async fn a_wrong_open_document_is_structured_and_never_reaches_the_write() { + let dir = tempfile::tempdir().unwrap(); + let requested = super::mounting_hole_tests::blank_board(dir.path()); + let other = dir.path().join("other.kicad_pcb"); + std::fs::write(&other, "(kicad_pcb)\n").unwrap(); + let before = std::fs::read(&requested).unwrap(); + let address = spawn_kicad_holding_board(&other, |_| None); + let ctx = ctx_talking_to(address); + let called = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let called_in_write = called.clone(); + + let outcome = attempt_ipc_write(&ctx, &requested, "test write", move |_| { + called_in_write.store(true, std::sync::atomic::Ordering::SeqCst); + Ok(()) + }) + .await + .unwrap(); + + let BoardWrite::Refused(result) = outcome else { + panic!("wrong document must be a refusal") + }; + assert_eq!( + crate::mcp::error::extract_error_kind(&result).as_deref(), + Some("wrong_document") + ); + assert!(!called.load(std::sync::atomic::Ordering::SeqCst)); + assert_eq!(std::fs::read(&requested).unwrap(), before); + assert!(!ctx.board_session.was_observed_live(&requested)); + } + #[tokio::test] async fn a_file_only_guard_blocks_a_previously_live_board_after_transport_loss() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/konnect-core/src/tools/pcb_components.rs b/crates/konnect-core/src/tools/pcb_components.rs index dc08223f..c174dda5 100644 --- a/crates/konnect-core/src/tools/pcb_components.rs +++ b/crates/konnect-core/src/tools/pcb_components.rs @@ -42,6 +42,9 @@ macro_rules! ipc { ))) } Err(konnect_ipc::IpcFailure::Rejected(msg)) => return Ok(CallToolResult::error(msg)), + Err(konnect_ipc::IpcFailure::Target { error, .. }) => { + return Ok(crate::tools::ipc_target_error_result(&error)) + } } }}; } @@ -3209,6 +3212,9 @@ async fn handle_get_component_pads( Err(konnect_ipc::IpcFailure::Rejected(message)) => { return Ok(CallToolResult::error(message)); } + Err(konnect_ipc::IpcFailure::Target { error, .. }) => { + return Ok(crate::tools::ipc_target_error_result(&error)); + } } let content = std::fs::read_to_string(&board_path)?; diff --git a/crates/konnect-core/src/tools/pcb_routing.rs b/crates/konnect-core/src/tools/pcb_routing.rs index a99f4840..1ae948df 100644 --- a/crates/konnect-core/src/tools/pcb_routing.rs +++ b/crates/konnect-core/src/tools/pcb_routing.rs @@ -23,6 +23,9 @@ macro_rules! ipc { let requested_board = get_path($args, "board")?; match with_board_ipc_classified($ctx, &requested_board, move |$c| $body).await? { Ok(v) => v, + Err(konnect_ipc::IpcFailure::Target { error, .. }) => { + return Ok(crate::tools::ipc_target_error_result(&error)) + } Err(error) => { return Ok(CallToolResult::error(format!( "KiCAD must be running with the board loaded (IPC error: {})", diff --git a/crates/konnect-ipc/src/client.rs b/crates/konnect-ipc/src/client.rs index f8ee6e16..cb2ce86f 100644 --- a/crates/konnect-ipc/src/client.rs +++ b/crates/konnect-ipc/src/client.rs @@ -309,6 +309,69 @@ impl std::fmt::Display for TransportUnreachable { impl std::error::Error for TransportUnreachable {} +/// A board-bearing operation could not resolve one exact live KiCad document. +/// +/// This remains a typed marker through `anyhow` so the MCP layer can distinguish +/// a wrong, ambiguous, or stale document target from a transport failure or an +/// ordinary KiCad command rejection. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BoardTargetError { + NoOpenDocuments { + requested: String, + }, + WrongDocument { + requested: String, + open_documents: Vec, + }, + AmbiguousDocument { + requested: String, + candidates: Vec, + }, + StaleDocument { + requested: String, + previously_bound: String, + open_documents: Vec, + }, +} + +impl std::fmt::Display for BoardTargetError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NoOpenDocuments { requested } => write!( + formatter, + "requested board '{requested}' cannot be targeted because no PCB document is open in KiCad" + ), + Self::WrongDocument { + requested, + open_documents, + } => write!( + formatter, + "requested board '{requested}' is not open in KiCad (open boards: {})", + open_documents.join(", ") + ), + Self::AmbiguousDocument { + requested, + candidates, + } => write!( + formatter, + "requested board '{requested}' matches multiple open KiCad documents: {}", + candidates.join(", ") + ), + Self::StaleDocument { + requested, + previously_bound, + open_documents, + } => write!( + formatter, + "requested board '{requested}' was bound to '{previously_bound}', but that document is no longer uniquely open (open boards: {})", + open_documents.join(", ") + ), + } + } +} + +impl std::error::Error for BoardTargetError {} + /// Why an IPC operation failed, for callers deciding whether a file-based /// fallback is safe. /// @@ -325,6 +388,10 @@ impl std::error::Error for TransportUnreachable {} pub enum IpcFailure { Unreachable(String), Rejected(String), + Target { + error: BoardTargetError, + message: String, + }, } impl IpcFailure { @@ -333,7 +400,15 @@ impl IpcFailure { /// message text. pub fn from_error(error: anyhow::Error) -> Self { let message = format!("{error:#}"); - if error + if let Some(target) = error + .chain() + .find_map(|cause| cause.downcast_ref::().cloned()) + { + IpcFailure::Target { + error: target, + message, + } + } else if error .chain() .any(|cause| cause.is::()) { @@ -345,7 +420,9 @@ impl IpcFailure { pub fn message(&self) -> &str { match self { - IpcFailure::Unreachable(message) | IpcFailure::Rejected(message) => message, + IpcFailure::Unreachable(message) + | IpcFailure::Rejected(message) + | IpcFailure::Target { message, .. } => message, } } } @@ -360,6 +437,13 @@ pub struct KiCadIpcClient { socket_path: String, kicad_token: String, client_name: String, + bound_board: std::sync::Mutex>, +} + +#[derive(Clone)] +struct BoundBoardTarget { + requested: PathBuf, + document: kiapi::common::types::DocumentSpecifier, } impl KiCadIpcClient { @@ -376,6 +460,7 @@ impl KiCadIpcClient { socket_path: effective_path, kicad_token: std::env::var("KICAD_API_TOKEN").unwrap_or_default(), client_name: format!("konnect-{}", std::process::id()), + bound_board: std::sync::Mutex::new(None), } } @@ -531,12 +616,53 @@ impl KiCadIpcClient { .collect()) } - /// Get the first open PCB's DocumentSpecifier (needed for most commands). + /// Get the uniquely targeted PCB document for generic board helpers. + /// + /// Once [`Self::find_open_board`] has proven a requested document, this + /// method re-observes the open-document set and carries that exact target + /// forward. It never falls back to the first open board. Without an + /// explicit binding, the legacy single-open-document behavior is retained; + /// multiple documents are an ambiguity rather than an ordering decision. fn get_board_document(&self) -> Result { let docs = self.get_open_documents()?; - docs.into_iter().next().ok_or_else(|| { - anyhow::anyhow!("No PCB document is open in KiCAD. Open a board file first.") - }) + let bound = self + .bound_board + .lock() + .map_err(|_| anyhow::anyhow!("bound board target lock is poisoned"))? + .clone(); + + if let Some(bound) = bound { + return match select_requested_board(&docs, &bound.requested) { + Ok(document) => { + self.bind_board(bound.requested, document.clone())?; + Ok(document) + } + Err(error @ BoardTargetError::AmbiguousDocument { .. }) => { + Err(anyhow::Error::new(error)) + } + Err(_) => Err(anyhow::Error::new(BoardTargetError::StaleDocument { + requested: bound.requested.display().to_string(), + previously_bound: board_document_label(&bound.document), + open_documents: board_document_labels(&docs), + })), + }; + } + + match docs.as_slice() { + [] => Err(anyhow::Error::new(BoardTargetError::NoOpenDocuments { + requested: "".to_string(), + })), + [document] => { + if let Some(path) = board_document_path(document) { + self.bind_board(path, document.clone())?; + } + Ok(document.clone()) + } + _ => Err(anyhow::Error::new(BoardTargetError::AmbiguousDocument { + requested: "".to_string(), + candidates: board_document_labels(&docs), + })), + } } /// Find the open document matching `requested`, so a path-bearing MCP @@ -550,23 +676,25 @@ impl KiCadIpcClient { requested: &Path, ) -> Result { let docs = self.get_open_documents()?; - if docs.is_empty() { - anyhow::bail!("No PCB document is open in KiCAD. Open a board file first."); - } - let mut open_names = Vec::new(); - for doc in docs { - if let Some(path) = board_document_path(&doc) { - if paths_refer_to_same_board(requested, &path) { - return Ok(doc); - } - open_names.push(path.display().to_string()); - } - } - anyhow::bail!( - "requested board '{}' is not open in KiCAD (open boards: {})", - requested.display(), - open_names.join(", ") - ) + let document = select_requested_board(&docs, requested).map_err(anyhow::Error::new)?; + self.bind_board(requested.to_path_buf(), document.clone())?; + Ok(document) + } + + fn bind_board( + &self, + requested: PathBuf, + document: kiapi::common::types::DocumentSpecifier, + ) -> Result<()> { + *self + .bound_board + .lock() + .map_err(|_| anyhow::anyhow!("bound board target lock is poisoned"))? = + Some(BoundBoardTarget { + requested, + document, + }); + Ok(()) } /// Fail closed unless the requested board is open in the IPC session. @@ -2581,6 +2709,48 @@ fn board_document_path(document: &kiapi::common::types::DocumentSpecifier) -> Op .or(Some(path)) } +fn board_document_label(document: &kiapi::common::types::DocumentSpecifier) -> String { + board_document_path(document) + .map(|path| path.display().to_string()) + .unwrap_or_else(|| "".to_string()) +} + +fn board_document_labels(documents: &[kiapi::common::types::DocumentSpecifier]) -> Vec { + documents.iter().map(board_document_label).collect() +} + +fn select_requested_board( + documents: &[kiapi::common::types::DocumentSpecifier], + requested: &Path, +) -> std::result::Result { + let requested_label = requested.display().to_string(); + if documents.is_empty() { + return Err(BoardTargetError::NoOpenDocuments { + requested: requested_label, + }); + } + + let matches = documents + .iter() + .filter(|document| { + board_document_path(document) + .is_some_and(|path| paths_refer_to_same_board(requested, &path)) + }) + .cloned() + .collect::>(); + match matches.as_slice() { + [document] => Ok(document.clone()), + [] => Err(BoardTargetError::WrongDocument { + requested: requested_label, + open_documents: board_document_labels(documents), + }), + _ => Err(BoardTargetError::AmbiguousDocument { + requested: requested_label, + candidates: board_document_labels(&matches), + }), + } +} + fn paths_refer_to_same_board(requested: &Path, active: &Path) -> bool { match (requested.canonicalize(), active.canonicalize()) { (Ok(requested), Ok(active)) => requested == active, diff --git a/crates/konnect-ipc/src/lib.rs b/crates/konnect-ipc/src/lib.rs index 7f62512f..37d0b990 100644 --- a/crates/konnect-ipc/src/lib.rs +++ b/crates/konnect-ipc/src/lib.rs @@ -6,5 +6,5 @@ pub mod client; pub mod transform; pub mod types; -pub use client::{IpcFailure, KiCadIpcClient, TransportUnreachable}; +pub use client::{BoardTargetError, IpcFailure, KiCadIpcClient, TransportUnreachable}; pub use types::*; diff --git a/crates/konnect-ipc/tests/mock_server_test.rs b/crates/konnect-ipc/tests/mock_server_test.rs index 00d5a3cc..f8406c44 100644 --- a/crates/konnect-ipc/tests/mock_server_test.rs +++ b/crates/konnect-ipc/tests/mock_server_test.rs @@ -936,6 +936,197 @@ fn doc_for(filename: &str) -> kiapi::common::types::DocumentSpecifier { } } +#[test] +fn generic_read_and_write_helpers_keep_the_bound_named_board() { + let captured = Arc::new(Mutex::new(Vec::<(String, String)>::new())); + let captured_in_mock = captured.clone(); + let mock = spawn_mock(move |request| { + let message = request.message.expect("request must pack a command"); + if message.type_url.ends_with("GetOpenDocuments") { + let response = kiapi::common::commands::GetOpenDocumentsResponse { + documents: vec![doc_for("other.kicad_pcb"), doc_for("target.kicad_pcb")], + }; + return Some(reply_with(builders::pack_any( + &response, + "kiapi.common.commands.GetOpenDocumentsResponse", + ))); + } + if message.type_url.ends_with("GetItems") { + let command = + kiapi::common::commands::GetItems::decode(message.value.as_slice()).unwrap(); + let document = command.header.unwrap().document.unwrap(); + captured_in_mock + .lock() + .unwrap() + .push(("read".to_string(), board_filename(&document))); + let response = kiapi::common::commands::GetItemsResponse { + header: None, + status: kiapi::common::types::ItemRequestStatus::IrsOk as i32, + items: Vec::new(), + }; + return Some(reply_with(builders::pack_any( + &response, + "kiapi.common.commands.GetItemsResponse", + ))); + } + if message.type_url.ends_with("CreateItems") { + let command = + kiapi::common::commands::CreateItems::decode(message.value.as_slice()).unwrap(); + let document = command.header.unwrap().document.unwrap(); + captured_in_mock + .lock() + .unwrap() + .push(("write".to_string(), board_filename(&document))); + let response = kiapi::common::commands::CreateItemsResponse { + header: None, + status: kiapi::common::types::ItemRequestStatus::IrsOk as i32, + created_items: vec![creation_result( + kiapi::common::commands::ItemStatusCode::IscOk, + "", + )], + }; + return Some(reply_with(builders::pack_any( + &response, + "kiapi.common.commands.CreateItemsResponse", + ))); + } + panic!("unexpected command {}", message.type_url); + }); + + let client = KiCadIpcClient::new(&mock.url); + client + .find_open_board(std::path::Path::new("target.kicad_pcb")) + .expect("bind target"); + client + .get_items(kiapi::common::types::KiCadObjectType::KotPcbFootprint) + .expect("generic read"); + client + .create_items(vec![any_item()]) + .expect("generic write"); + + assert_eq!( + *captured.lock().unwrap(), + [ + ("read".to_string(), "target.kicad_pcb".to_string()), + ("write".to_string(), "target.kicad_pcb".to_string()), + ] + ); +} + +#[test] +fn zero_wrong_and_duplicate_open_document_sets_are_typed_target_failures() { + let cases = [ + ( + Vec::new(), + "no_open", + std::path::PathBuf::from("target.kicad_pcb"), + ), + ( + vec![doc_for("other.kicad_pcb")], + "wrong", + std::path::PathBuf::from("target.kicad_pcb"), + ), + ( + vec![doc_for("target.kicad_pcb"), doc_for("target.kicad_pcb")], + "ambiguous", + std::path::PathBuf::from("target.kicad_pcb"), + ), + ]; + + for (documents, expected, requested) in cases { + let mock = spawn_mock(move |request| { + let message = request.message.expect("request must pack a command"); + assert!(message.type_url.ends_with("GetOpenDocuments")); + let response = kiapi::common::commands::GetOpenDocumentsResponse { + documents: documents.clone(), + }; + Some(reply_with(builders::pack_any( + &response, + "kiapi.common.commands.GetOpenDocumentsResponse", + ))) + }); + let client = KiCadIpcClient::new(&mock.url); + let failure = konnect_ipc::IpcFailure::from_error( + client + .find_open_board(&requested) + .expect_err("target selection must refuse"), + ); + match (expected, failure) { + ( + "no_open", + konnect_ipc::IpcFailure::Target { + error: konnect_ipc::BoardTargetError::NoOpenDocuments { .. }, + .. + }, + ) + | ( + "wrong", + konnect_ipc::IpcFailure::Target { + error: konnect_ipc::BoardTargetError::WrongDocument { .. }, + .. + }, + ) + | ( + "ambiguous", + konnect_ipc::IpcFailure::Target { + error: konnect_ipc::BoardTargetError::AmbiguousDocument { .. }, + .. + }, + ) => {} + (_, other) => panic!("unexpected target classification: {other:?}"), + } + } +} + +#[test] +fn a_bound_document_disappearing_before_a_generic_command_is_stale() { + let observations = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let observations_in_mock = observations.clone(); + let mock = spawn_mock(move |request| { + let message = request.message.expect("request must pack a command"); + assert!(message.type_url.ends_with("GetOpenDocuments")); + let count = observations_in_mock.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let response = kiapi::common::commands::GetOpenDocumentsResponse { + documents: if count == 0 { + vec![doc_for("target.kicad_pcb")] + } else { + vec![doc_for("other.kicad_pcb")] + }, + }; + Some(reply_with(builders::pack_any( + &response, + "kiapi.common.commands.GetOpenDocumentsResponse", + ))) + }); + + let client = KiCadIpcClient::new(&mock.url); + client + .find_open_board(std::path::Path::new("target.kicad_pcb")) + .expect("initial target binding"); + let failure = konnect_ipc::IpcFailure::from_error( + client + .get_items(kiapi::common::types::KiCadObjectType::KotPcbFootprint) + .expect_err("closed bound document must refuse before GetItems"), + ); + assert!(matches!( + failure, + konnect_ipc::IpcFailure::Target { + error: konnect_ipc::BoardTargetError::StaleDocument { .. }, + .. + } + )); + assert_eq!(observations.load(std::sync::atomic::Ordering::SeqCst), 2); +} + +fn board_filename(document: &kiapi::common::types::DocumentSpecifier) -> String { + match document.identifier.as_ref() { + Some(kiapi::common::types::document_specifier::Identifier::BoardFilename(name)) => { + name.clone() + } + other => panic!("expected board filename, got {other:?}"), + } +} + fn record_doc( slot: &std::sync::Arc>>, header: &Option, From 3f9f78f1d0d3097d51972ef991ec5fc797a43f78 Mon Sep 17 00:00:00 2001 From: dubesinhower Date: Sun, 30 Aug 2026 13:13:18 -0400 Subject: [PATCH 12/16] feat(navigation): observe editor capabilities --- DEV.md | 8 +- README.md | 4 +- crates/konnect-core/src/mcp/error.rs | 19 ++ crates/konnect-core/src/router/registry.rs | 7 + .../src/tools/editor_navigation.rs | 213 ++++++++++++ crates/konnect-core/src/tools/mod.rs | 1 + crates/konnect-ipc/src/client.rs | 304 +++++++++++++++++- crates/konnect-ipc/src/lib.rs | 5 +- crates/konnect-ipc/src/types.rs | 109 +++++++ crates/konnect-ipc/tests/live_kicad_test.rs | 25 ++ crates/konnect-ipc/tests/mock_server_test.rs | 197 ++++++++++++ docs/KICAD_INTEGRATION.md | 16 + docs/TROUBLESHOOTING.md | 2 +- packaging/metadata.json | 4 +- plugin/plugin.json | 2 +- tool-directory.md | 14 +- 16 files changed, 905 insertions(+), 25 deletions(-) create mode 100644 crates/konnect-core/src/tools/editor_navigation.rs diff --git a/DEV.md b/DEV.md index b1eae223..0880fa1a 100644 --- a/DEV.md +++ b/DEV.md @@ -68,7 +68,7 @@ Konnect/ │ │ ├── stdio.rs # Line-by-line JSON-RPC over stdin/stdout (default) │ │ └── http.rs # Streamable HTTP: POST + GET (SSE) on /mcp (transport = "http" / "both") │ │ -│ ├── konnect-core/ # All tool logic (20 toolsets) +│ ├── konnect-core/ # All tool logic (21 toolsets) │ │ └── src/ │ │ ├── mcp/ │ │ │ ├── protocol.rs # MCP JSON-RPC 2.0 types @@ -316,7 +316,7 @@ Source: [`crates/konnect-core/src/observability.rs`](crates/konnect-core/src/obs ## Tool Routing (Starter Kit + On-Demand Loading) -The server does NOT expose all 221 tools (228 total with the 7 meta-tools) in `tools/list` by default — that would cost ~23K tokens of context on every listing. Instead: +The server does NOT expose all 222 tools (229 total with the 7 meta-tools) in `tools/list` by default — that would cost ~23K tokens of context on every listing. Instead: - **Startup**: only `STARTER_KIT` toolsets are pre-loaded (see `router/registry.rs::STARTER_KIT`). Currently: `project`, `config`. Combined with the 7 meta-tools, baseline `tools/list` is 21 tools ≈ 2K tokens. - **On demand**: the LLM reads `list_toolboxes` → calls `load_toolset(name)` to expose a toolset's tools in subsequent `tools/list` responses. `unload_toolset(name)` prunes them when the task shifts. @@ -391,9 +391,9 @@ convention for other `kicad-cli`-calling code. ## Current Stats -- **20 toolsets, 221 tools** + 7 meta-tools (4 routing + 2 observability + 1 runtime diagnostic — see `tool-directory.md`) +- **21 toolsets, 222 tools** + 7 meta-tools (4 routing + 2 observability + 1 runtime diagnostic — see `tool-directory.md`) - Baseline `tools/list`: 21 tools / ~2K tokens (starter kit + meta-tools) -- Full-catalog `tools/list` (all loaded): 228 tools (221 registered + 7 meta) / ~25K tokens +- Full-catalog `tools/list` (all loaded): 229 tools (222 registered + 7 meta) / ~25K tokens - **0 IPC stubs** (all protobuf methods implemented) - **0 unimplemented tools** - **Specctra DSN/SES are PCB-editor operations**, not `kicad-cli` commands. diff --git a/README.md b/README.md index ffb1e15b..ef4bf323 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Rust binary — that lets Claude and other AI assistants design schematics and PCBs through the [Model Context Protocol](https://modelcontextprotocol.io) (MCP). -**221 tools across 20 on-demand toolsets.** Schematic capture, PCB layout and +**222 tools across 21 on-demand toolsets.** Schematic capture, PCB layout and routing, ERC/DRC, design-review audits, JLCPCB part search, reference circuits, and a full manufacturing export pipeline — with bundled skills and agents that teach Claude KiCAD conventions out of the box. @@ -70,7 +70,7 @@ through its own S-expression engine with atomic writes (write, fsync, rename), U preservation, and round-trip tests — no third-party schematic library with known gaps, no text-manipulation workarounds. -**Context economy is a feature.** Exposing all 221 tools to an LLM costs roughly 23K +**Context economy is a feature.** Exposing all 222 tools to an LLM costs roughly 23K tokens of context on every listing. Konnect's router loads a starter kit (~2K tokens) and lets the model pull in toolsets on demand — plus built-in observability (`get_recent_calls`, `server_stats`, JSONL call logs) so the model can diagnose its diff --git a/crates/konnect-core/src/mcp/error.rs b/crates/konnect-core/src/mcp/error.rs index 272ddcea..fd8f44d8 100644 --- a/crates/konnect-core/src/mcp/error.rs +++ b/crates/konnect-core/src/mcp/error.rs @@ -66,6 +66,15 @@ pub enum ToolErrorKind { /// The caller named a target, but its observed editor or document state /// no longer agrees with the state required to mutate it safely. StaleTarget { target: String, reason: String }, + /// No live editor endpoint is configured or reachable for the requested + /// semantic operation. + EditorUnavailable { editor: String, reason: String }, + /// The running KiCad version or the bundled stable protocol does not + /// provide a capability the caller requested. + UnsupportedCapability { + capability: String, + kicad_version: Option, + }, /// A board was live earlier in this server process, but IPC is now gone; /// its saved file may be stale relative to lost editor state. UnsafeFileFallback { path: String }, @@ -88,6 +97,8 @@ impl ToolErrorKind { Self::AmbiguousTarget { .. } => "ambiguous_target", Self::WrongDocument { .. } => "wrong_document", Self::StaleTarget { .. } => "stale_target", + Self::EditorUnavailable { .. } => "editor_unavailable", + Self::UnsupportedCapability { .. } => "unsupported_capability", Self::UnsafeFileFallback { .. } => "unsafe_file_fallback", Self::HandlerError { .. } => "handler_error", } @@ -200,6 +211,14 @@ mod tests { target: "p".into(), reason: "r".into(), }, + ToolErrorKind::EditorUnavailable { + editor: "pcb".into(), + reason: "closed".into(), + }, + ToolErrorKind::UnsupportedCapability { + capability: "activate_sheet".into(), + kicad_version: Some("10.0.5".into()), + }, ToolErrorKind::UnsafeFileFallback { path: "p".into() }, ToolErrorKind::HandlerError { reason: "r".into() }, ]; diff --git a/crates/konnect-core/src/router/registry.rs b/crates/konnect-core/src/router/registry.rs index 3b911a16..ef79231f 100644 --- a/crates/konnect-core/src/router/registry.rs +++ b/crates/konnect-core/src/router/registry.rs @@ -24,6 +24,12 @@ pub static ALL_TOOLSETS: &[ToolsetMeta] = &[ category: "project", tool_count: 7, }, + ToolsetMeta { + name: "editor_navigation", + description: "Observe and semantically navigate exact KiCad editor, document, sheet, selection, and cross-probe context", + category: "project", + tool_count: 1, + }, ToolsetMeta { name: "sch_components", description: "Add, edit, move, rotate, and delete schematic symbols, and set the page size", @@ -145,6 +151,7 @@ pub fn tools_for(name: &str) -> Option> { use crate::tools::*; match name { "project" => Some(project::tools()), + "editor_navigation" => Some(editor_navigation::tools()), "sch_components" => Some(sch_components::tools()), "sch_wiring" => Some(sch_wiring::tools()), "sch_bus" => Some(sch_bus::tools()), diff --git a/crates/konnect-core/src/tools/editor_navigation.rs b/crates/konnect-core/src/tools/editor_navigation.rs new file mode 100644 index 00000000..ad292b33 --- /dev/null +++ b/crates/konnect-core/src/tools/editor_navigation.rs @@ -0,0 +1,213 @@ +//! Semantic KiCad editor observation and navigation. +//! +//! Public names in this module are provisional while upstream design issue +//! #395 is under review. The implementation deliberately exposes only typed +//! semantic operations; KiCad's unstable raw `RunAction` strings are not part +//! of this surface. + +use crate::mcp::{error::ToolErrorKind, protocol::CallToolResult}; +use crate::tool; +use crate::tools::{ToolContext, ToolDef}; +use serde_json::json; + +pub fn tools() -> Vec { + vec![tool!( + "get_editor_state", + "Observe the configured KiCad IPC endpoint: running KiCad version, addressable schematic and PCB editors, exact open document identities, and semantic navigation capabilities. Active editor/document/sheet fields are null when KiCad has no stable typed query; open-document order is never treated as active state.", + json!({ + "type": "object", + "properties": {}, + "required": [] + }), + |args, ctx| async move { handle_get_editor_state(args, ctx).await } + )] +} + +async fn handle_get_editor_state( + _args: &serde_json::Value, + ctx: &ToolContext, +) -> anyhow::Result { + let address = ctx.config.ipc_address.clone(); + if address.is_empty() { + return Ok(editor_unavailable("no KiCad IPC endpoint is configured")); + } + + let result = tokio::task::spawn_blocking(move || { + konnect_ipc::KiCadIpcClient::new(address).observe_editor_state() + }) + .await?; + match result { + Ok(observation) => Ok(CallToolResult::json(&observation)), + Err(error) => { + if let Some(document_error) = error + .chain() + .find_map(|cause| cause.downcast_ref::()) + { + return Ok(CallToolResult::error_kind( + ToolErrorKind::StaleTarget { + target: format!("{} editor document state", document_error.editor.as_str()), + reason: document_error.reason.clone(), + }, + document_error.to_string(), + )); + } + if let Some(status) = konnect_ipc::ApiStatusError::from_error(&error) { + if status.is_unsupported() { + return Ok(CallToolResult::error_kind( + ToolErrorKind::UnsupportedCapability { + capability: "editor_state_observation".to_string(), + kicad_version: None, + }, + "The running KiCad endpoint does not support editor-state observation.", + )); + } + return Ok(CallToolResult::error_kind( + ToolErrorKind::StaleTarget { + target: "configured KiCad IPC endpoint".to_string(), + reason: status.code_name.clone(), + }, + "The configured KiCad endpoint responded but could not provide a stable editor-state observation.", + )); + } + match konnect_ipc::IpcFailure::from_error(error) { + konnect_ipc::IpcFailure::Unreachable(_) => Ok(editor_unavailable( + "the configured KiCad IPC endpoint is unreachable", + )), + _ => Ok(CallToolResult::error_kind( + ToolErrorKind::StaleTarget { + target: "configured KiCad IPC endpoint".to_string(), + reason: "the endpoint did not return a complete typed observation" + .to_string(), + }, + "The configured KiCad endpoint did not return a complete typed editor-state observation.", + )), + } + } + } +} + +fn editor_unavailable(reason: &str) -> CallToolResult { + CallToolResult::error_kind( + ToolErrorKind::EditorUnavailable { + editor: "configured_endpoint".to_string(), + reason: reason.to_string(), + }, + format!("KiCad editor state is unavailable: {reason}."), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mcp::error::extract_error_kind; + use crate::mcp::protocol::ToolContent; + use crate::router::ToolRouter; + use crate::tools::ServerConfig; + use konnect_ipc::builders; + use konnect_ipc::gen::kiapi; + use nng::options::Options; + use prost::Message; + use std::sync::Arc; + use std::time::Duration; + + fn context(ipc_address: String) -> ToolContext { + ToolContext::new( + ServerConfig { + ipc_address, + ..Default::default() + }, + Arc::new(ToolRouter::new()), + ) + } + + fn spawn_observation_mock() -> String { + static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let url = format!( + "inproc://editor-state-core-{}", + NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + ); + let socket = nng::Socket::new(nng::Protocol::Rep0).expect("mock socket"); + socket + .set_opt::(Some(Duration::from_secs(10))) + .expect("timeout"); + socket.listen(&url).expect("listen"); + std::thread::spawn(move || { + for _ in 0..3 { + let message = socket.recv().expect("request"); + let request = + kiapi::common::ApiRequest::decode(message.as_slice()).expect("decode request"); + let command = request.message.expect("command"); + let response_any = if command.type_url.ends_with("GetVersion") { + builders::pack_any( + &kiapi::common::commands::GetVersionResponse { + version: Some(kiapi::common::types::KiCadVersion { + major: 10, + minor: 0, + patch: 5, + full_version: "10.0.5".to_string(), + }), + }, + "kiapi.common.commands.GetVersionResponse", + ) + } else { + builders::pack_any( + &kiapi::common::commands::GetOpenDocumentsResponse { + documents: Vec::new(), + }, + "kiapi.common.commands.GetOpenDocumentsResponse", + ) + }; + let response = kiapi::common::ApiResponse { + status: Some(kiapi::common::ApiResponseStatus { + status: kiapi::common::ApiStatusCode::AsOk as i32, + error_message: String::new(), + }), + header: None, + message: Some(response_any), + }; + socket + .send(nng::Message::from(response.encode_to_vec().as_slice())) + .expect("response"); + } + }); + url + } + + #[test] + fn public_tool_is_read_only_and_takes_no_required_arguments() { + let definitions = tools(); + assert_eq!(definitions.len(), 1); + assert_eq!(definitions[0].name, "get_editor_state"); + assert_eq!(definitions[0].input_schema["required"], json!([])); + } + + #[tokio::test] + async fn unconfigured_endpoint_is_a_structured_editor_unavailable_refusal() { + let result = handle_get_editor_state(&json!({}), &context(String::new())) + .await + .expect("handler result"); + assert!(result.is_error); + assert_eq!( + extract_error_kind(&result).as_deref(), + Some("editor_unavailable") + ); + } + + #[tokio::test] + async fn public_result_is_derived_from_typed_ipc_observation() { + let result = handle_get_editor_state(&json!({}), &context(spawn_observation_mock())) + .await + .expect("handler result"); + assert!(!result.is_error); + let ToolContent::Text { text } = &result.content[0] else { + panic!("expected text result"); + }; + let body: serde_json::Value = serde_json::from_str(text).expect("json result"); + assert_eq!(body["kicad_version"]["full_version"], "10.0.5"); + assert_eq!(body["evidence_source"], "kicad_ipc"); + assert_eq!(body["editors"].as_array().map(Vec::len), Some(2)); + assert!(body["active_editor"].is_null()); + assert!(body["active_document"].is_null()); + assert!(body["active_sheet_instance"].is_null()); + } +} diff --git a/crates/konnect-core/src/tools/mod.rs b/crates/konnect-core/src/tools/mod.rs index 0e5fbe14..3d742ae5 100644 --- a/crates/konnect-core/src/tools/mod.rs +++ b/crates/konnect-core/src/tools/mod.rs @@ -4,6 +4,7 @@ mod board_session; pub mod cli; pub mod config; pub mod design_review; +pub mod editor_navigation; mod footprint_graphics; mod footprint_metadata; mod footprint_models; diff --git a/crates/konnect-ipc/src/client.rs b/crates/konnect-ipc/src/client.rs index cb2ce86f..b8e58b49 100644 --- a/crates/konnect-ipc/src/client.rs +++ b/crates/konnect-ipc/src/client.rs @@ -309,6 +309,62 @@ impl std::fmt::Display for TransportUnreachable { impl std::error::Error for TransportUnreachable {} +/// Typed KiCad response status for a request that completed a round trip. +/// +/// Keeping the numeric status in the error chain lets capability discovery +/// distinguish an unsupported/unhandled command from an unreachable editor +/// without matching human-readable error text. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ApiStatusError { + pub code: i32, + pub code_name: String, + pub message: String, +} + +impl ApiStatusError { + pub fn from_error(error: &anyhow::Error) -> Option<&Self> { + error.chain().find_map(|cause| cause.downcast_ref::()) + } + + pub fn is_unsupported(&self) -> bool { + self.code == kiapi::common::ApiStatusCode::AsUnhandled as i32 + || self.code == kiapi::common::ApiStatusCode::AsUnimplemented as i32 + } +} + +impl std::fmt::Display for ApiStatusError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + formatter, + "KiCad IPC error: {} ({})", + self.message, self.code_name + ) + } +} + +impl std::error::Error for ApiStatusError {} + +/// A live document reply did not carry the identity required by its requested +/// editor kind. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IpcDocumentObservationError { + pub editor: IpcEditorKind, + pub reason: String, +} + +impl std::fmt::Display for IpcDocumentObservationError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + formatter, + "KiCad returned a malformed {} document identity: {}", + self.editor.as_str(), + self.reason + ) + } +} + +impl std::error::Error for IpcDocumentObservationError {} + /// A board-bearing operation could not resolve one exact live KiCad document. /// /// This remains a typed marker through `anyhow` so the MCP layer can distinguish @@ -571,7 +627,11 @@ impl KiCadIpcClient { status.error_message.clone() }; debug!("[BETA] IPC ← error: {} ({})", msg, code.as_str_name()); - anyhow::bail!("KiCad IPC error: {} ({})", msg, code.as_str_name()); + return Err(anyhow::Error::new(ApiStatusError { + code: code as i32, + code_name: code.as_str_name().to_string(), + message: msg, + })); } debug!("[BETA] IPC ← OK"); @@ -592,18 +652,113 @@ impl KiCadIpcClient { } } + /// Observe the running KiCad version through the typed IPC command. + pub fn get_kicad_version(&self) -> Result { + let command = kiapi::common::commands::GetVersion {}; + let response = unpack_required::( + self.send_command(&command, "kiapi.common.commands.GetVersion")?, + "GetVersion", + )?; + let version = response + .version + .context("GetVersion response did not contain a KiCad version")?; + Ok(IpcKiCadVersion { + major: version.major, + minor: version.minor, + patch: version.patch, + full_version: version.full_version, + }) + } + + /// Query open documents for one explicit editor type. + pub fn get_open_documents_for( + &self, + editor: IpcEditorKind, + ) -> Result> { + let document_type = match editor { + IpcEditorKind::Schematic => kiapi::common::types::DocumentType::DoctypeSchematic, + IpcEditorKind::Pcb => kiapi::common::types::DocumentType::DoctypePcb, + }; + let command = kiapi::common::commands::GetOpenDocuments { + r#type: document_type as i32, + }; + let response = unpack_required::( + self.send_command(&command, "kiapi.common.commands.GetOpenDocuments")?, + "GetOpenDocuments", + )?; + Ok(response.documents) + } + + /// Observe the editor/document surface exposed by the configured endpoint. + /// + /// KiCad 10 does not expose a stable typed query for the foreground frame, + /// active document, or active schematic sheet. Those facts remain null and + /// the capability matrix states why; open-document order is never treated + /// as active state. + pub fn observe_editor_state(&self) -> Result { + let version = self.get_kicad_version()?; + let editors = [IpcEditorKind::Schematic, IpcEditorKind::Pcb] + .into_iter() + .map(|editor| self.observe_editor(editor, &version)) + .collect::>>()?; + Ok(IpcEditorStateObservation { + kicad_version: version, + evidence_source: "kicad_ipc".to_string(), + editors, + active_editor: None, + active_document: None, + active_sheet_instance: None, + limitations: vec![ + "The configured IPC endpoint cannot enumerate every running KiCad frame or endpoint." + .to_string(), + "KiCad 10 exposes no stable typed foreground-frame, active-document, or active-sheet query; open-document order is not active-state evidence." + .to_string(), + ], + }) + } + + fn observe_editor( + &self, + editor: IpcEditorKind, + version: &IpcKiCadVersion, + ) -> Result { + let documents = match self.get_open_documents_for(editor) { + Ok(documents) => documents, + Err(error) => { + if let Some(status) = ApiStatusError::from_error(&error) { + if status.is_unsupported() { + return Ok(IpcEditorObservation { + editor, + addressable: false, + documents: Vec::new(), + capabilities: editor_capabilities(editor, version, false), + unavailable_reason: Some(format!( + "GetOpenDocuments is {} on this endpoint", + status.code_name + )), + }); + } + } + return Err(error); + } + }; + + let documents = documents + .into_iter() + .map(|document| editor_document_from_specifier(editor, document)) + .collect::>>()?; + Ok(IpcEditorObservation { + editor, + addressable: true, + documents, + capabilities: editor_capabilities(editor, version, true), + unavailable_reason: None, + }) + } + /// Get the list of open documents (boards). pub fn get_open_documents(&self) -> Result> { - let cmd = kiapi::common::commands::GetOpenDocuments { - r#type: kiapi::common::types::DocumentType::DoctypePcb as i32, - }; - let response_any = self.send_command(&cmd, "kiapi.common.commands.GetOpenDocuments")?; - if let Some(any) = response_any { - let resp: kiapi::common::commands::GetOpenDocumentsResponse = unpack_any(&any)?; - Ok(resp.documents) - } else { - Ok(vec![]) - } + self.get_open_documents_for(IpcEditorKind::Pcb) } /// Resolve the filenames of every open PCB document, including relative @@ -2709,6 +2864,133 @@ fn board_document_path(document: &kiapi::common::types::DocumentSpecifier) -> Op .or(Some(path)) } +fn editor_document_from_specifier( + expected: IpcEditorKind, + document: kiapi::common::types::DocumentSpecifier, +) -> Result { + use kiapi::common::types::document_specifier::Identifier; + + let expected_type = match expected { + IpcEditorKind::Schematic => kiapi::common::types::DocumentType::DoctypeSchematic, + IpcEditorKind::Pcb => kiapi::common::types::DocumentType::DoctypePcb, + }; + if document.r#type != expected_type as i32 { + return Err(anyhow::Error::new(IpcDocumentObservationError { + editor: expected, + reason: format!( + "requested {}, observed {}", + expected_type.as_str_name(), + kiapi::common::types::DocumentType::try_from(document.r#type) + .map(|kind| kind.as_str_name().to_string()) + .unwrap_or_else(|_| document.r#type.to_string()) + ), + })); + } + + let project = document.project.as_ref().map(|project| IpcProjectIdentity { + name: project.name.clone(), + path: project.path.clone(), + }); + let (document_path, sheet_instance_path) = match (expected, document.identifier.as_ref()) { + (IpcEditorKind::Pcb, Some(Identifier::BoardFilename(filename))) if !filename.is_empty() => { + ( + board_document_path(&document).map(|path| path.display().to_string()), + None, + ) + } + (IpcEditorKind::Schematic, Some(Identifier::SheetPath(path))) + if !path.path.is_empty() && path.path.iter().all(|id| !id.value.is_empty()) => + { + ( + None, + Some(IpcSheetInstancePath { + kiids: path.path.iter().map(|id| id.value.clone()).collect(), + human_readable: path.path_human_readable.clone(), + }), + ) + } + _ => { + return Err(anyhow::Error::new(IpcDocumentObservationError { + editor: expected, + reason: "missing or empty document identifier".to_string(), + })); + } + }; + + Ok(IpcEditorDocument { + editor: expected, + project, + document_path, + sheet_instance_path, + }) +} + +fn editor_capabilities( + editor: IpcEditorKind, + version: &IpcKiCadVersion, + addressable: bool, +) -> IpcEditorCapabilities { + let available = |source: &str| IpcCapability { + availability: IpcCapabilityAvailability::Available, + evidence_source: source.to_string(), + reason: None, + }; + let unsupported = |source: &str, reason: &str| IpcCapability { + availability: IpcCapabilityAvailability::Unsupported, + evidence_source: source.to_string(), + reason: Some(reason.to_string()), + }; + + let documents = if addressable { + available("kicad_ipc_runtime_probe") + } else { + unsupported( + "kicad_ipc_runtime_probe", + "the configured endpoint did not handle this editor's document query", + ) + }; + let selection = if addressable && version.major >= 10 { + available("kicad_version_and_typed_protocol") + } else { + unsupported( + "kicad_version_and_typed_protocol", + "selection requires an addressable KiCad 10-or-newer editor endpoint", + ) + }; + let no_active_context = unsupported( + "konnect_bundled_kicad_protocol", + "no stable typed active-frame, active-document, or active-sheet query is available", + ); + let no_activation = unsupported( + "konnect_bundled_kicad_protocol", + match editor { + IpcEditorKind::Schematic => { + "no stable typed exact schematic/sheet activation command is available" + } + IpcEditorKind::Pcb => "no stable typed exact PCB activation command is available", + }, + ); + let no_reveal = unsupported( + "konnect_bundled_kicad_protocol", + "selection is typed, but reveal/center/fit has no stable typed command", + ); + let no_cross_probe = unsupported( + "konnect_navigation_mvp", + "semantic cross-probe resolution is not implemented in this slice", + ); + + IpcEditorCapabilities { + observe_documents: documents, + observe_active_context: no_active_context, + read_selection: selection.clone(), + mutate_selection: selection, + activate_document: no_activation.clone(), + activate_sheet: no_activation, + reveal_object: no_reveal, + cross_probe: no_cross_probe, + } +} + fn board_document_label(document: &kiapi::common::types::DocumentSpecifier) -> String { board_document_path(document) .map(|path| path.display().to_string()) diff --git a/crates/konnect-ipc/src/lib.rs b/crates/konnect-ipc/src/lib.rs index 37d0b990..98476806 100644 --- a/crates/konnect-ipc/src/lib.rs +++ b/crates/konnect-ipc/src/lib.rs @@ -6,5 +6,8 @@ pub mod client; pub mod transform; pub mod types; -pub use client::{BoardTargetError, IpcFailure, KiCadIpcClient, TransportUnreachable}; +pub use client::{ + ApiStatusError, BoardTargetError, IpcDocumentObservationError, IpcFailure, KiCadIpcClient, + TransportUnreachable, +}; pub use types::*; diff --git a/crates/konnect-ipc/src/types.rs b/crates/konnect-ipc/src/types.rs index 92dd5f05..3d58ad3e 100644 --- a/crates/konnect-ipc/src/types.rs +++ b/crates/konnect-ipc/src/types.rs @@ -1,6 +1,115 @@ use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; +/// KiCad design editor addressed by the typed IPC API. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum IpcEditorKind { + Schematic, + Pcb, +} + +impl IpcEditorKind { + pub fn as_str(self) -> &'static str { + match self { + Self::Schematic => "schematic", + Self::Pcb => "pcb", + } + } +} + +/// Runtime availability of one semantic editor capability. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum IpcCapabilityAvailability { + Available, + Unsupported, + Unknown, +} + +/// One capability statement and the evidence used to make it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct IpcCapability { + pub availability: IpcCapabilityAvailability, + pub evidence_source: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + +/// Capabilities relevant to the Priority 1 semantic navigation surface. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct IpcEditorCapabilities { + pub observe_documents: IpcCapability, + pub observe_active_context: IpcCapability, + pub read_selection: IpcCapability, + pub mutate_selection: IpcCapability, + pub activate_document: IpcCapability, + pub activate_sheet: IpcCapability, + pub reveal_object: IpcCapability, + pub cross_probe: IpcCapability, +} + +/// Running KiCad version observed through `GetVersion`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct IpcKiCadVersion { + pub major: u32, + pub minor: u32, + pub patch: u32, + pub full_version: String, +} + +/// Project identity carried by a live KiCad `DocumentSpecifier`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct IpcProjectIdentity { + pub name: String, + pub path: String, +} + +/// Canonical schematic instance identity carried by KiCad IPC. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct IpcSheetInstancePath { + pub kiids: Vec, + pub human_readable: String, +} + +/// One exact live document identity observed through KiCad IPC. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct IpcEditorDocument { + pub editor: IpcEditorKind, + pub project: Option, + /// Exact board path when KiCad provides one. KiCad 10 schematic document + /// specifiers carry a sheet path rather than a schematic filename, so this + /// is deliberately null for schematics instead of being inferred from disk. + pub document_path: Option, + pub sheet_instance_path: Option, +} + +/// Observation for one editor kind on the configured IPC endpoint. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct IpcEditorObservation { + pub editor: IpcEditorKind, + pub addressable: bool, + pub documents: Vec, + pub capabilities: IpcEditorCapabilities, + #[serde(skip_serializing_if = "Option::is_none")] + pub unavailable_reason: Option, +} + +/// Result of observing the configured KiCad IPC endpoint. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct IpcEditorStateObservation { + pub kicad_version: IpcKiCadVersion, + pub evidence_source: String, + pub editors: Vec, + /// KiCad 10 has no stable typed foreground-frame or active-document query. + /// These fields remain null rather than treating open-document order as + /// active state. + pub active_editor: Option, + pub active_document: Option, + pub active_sheet_instance: Option, + pub limitations: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct IpcVector2 { pub x: f64, diff --git a/crates/konnect-ipc/tests/live_kicad_test.rs b/crates/konnect-ipc/tests/live_kicad_test.rs index d9c431d3..60848526 100644 --- a/crates/konnect-ipc/tests/live_kicad_test.rs +++ b/crates/konnect-ipc/tests/live_kicad_test.rs @@ -143,6 +143,31 @@ fn load_board(path: &Path) -> SexpNode { parse_sexp(&source).expect("failed to parse live KiCad board") } +#[test] +#[ignore = "requires a running KiCad GUI with its IPC API enabled"] +fn editor_state_observation_reports_real_version_and_honest_capabilities() { + let socket = std::env::var("KICAD_API_SOCKET").expect("KICAD_API_SOCKET is required"); + let observation = KiCadIpcClient::new(socket) + .observe_editor_state() + .expect("editor-state observation failed"); + assert!( + observation.kicad_version.major >= 10, + "navigation requires KiCad 10 or newer, got {:?}", + observation.kicad_version + ); + assert_eq!(observation.evidence_source, "kicad_ipc"); + assert_eq!(observation.editors.len(), 2); + assert!(observation.active_editor.is_none()); + assert!(observation.active_document.is_none()); + assert!(observation.active_sheet_instance.is_none()); + assert!(observation.editors.iter().all(|editor| { + editor.addressable + || editor.unavailable_reason.as_deref().is_some_and(|reason| { + reason.contains("AS_UNHANDLED") || reason.contains("AS_UNIMPLEMENTED") + }) + })); +} + #[test] #[ignore = "requires a running KiCad GUI with its IPC API enabled"] fn moving_and_rotating_footprint_preserves_child_geometry() { diff --git a/crates/konnect-ipc/tests/mock_server_test.rs b/crates/konnect-ipc/tests/mock_server_test.rs index f8406c44..7bc3977f 100644 --- a/crates/konnect-ipc/tests/mock_server_test.rs +++ b/crates/konnect-ipc/tests/mock_server_test.rs @@ -111,6 +111,203 @@ fn open_board_response() -> kiapi::common::ApiResponse { )) } +fn version_response() -> kiapi::common::ApiResponse { + let response = kiapi::common::commands::GetVersionResponse { + version: Some(kiapi::common::types::KiCadVersion { + major: 10, + minor: 0, + patch: 5, + full_version: "10.0.5".to_string(), + }), + }; + reply_with(builders::pack_any( + &response, + "kiapi.common.commands.GetVersionResponse", + )) +} + +fn unsupported_response(code: kiapi::common::ApiStatusCode) -> kiapi::common::ApiResponse { + kiapi::common::ApiResponse { + status: Some(kiapi::common::ApiResponseStatus { + status: code as i32, + error_message: String::new(), + }), + header: None, + message: None, + } +} + +#[test] +fn editor_state_observation_keeps_live_sheet_identity_and_unsupported_context_honest() { + let mock = spawn_mock(|request| { + let message = request.message.expect("request must pack a command"); + if message.type_url.ends_with("GetVersion") { + return Some(version_response()); + } + if message.type_url.ends_with("GetOpenDocuments") { + let command = + kiapi::common::commands::GetOpenDocuments::decode(message.value.as_slice()) + .expect("decode GetOpenDocuments"); + if command.r#type == kiapi::common::types::DocumentType::DoctypeSchematic as i32 { + let response = kiapi::common::commands::GetOpenDocumentsResponse { + documents: vec![kiapi::common::types::DocumentSpecifier { + r#type: kiapi::common::types::DocumentType::DoctypeSchematic as i32, + project: Some(kiapi::common::types::ProjectSpecifier { + name: "controller".to_string(), + path: "C:/design/controller".to_string(), + }), + identifier: Some( + kiapi::common::types::document_specifier::Identifier::SheetPath( + kiapi::common::types::SheetPath { + path: vec![ + kiapi::common::types::Kiid { + value: "root-kiid".to_string(), + }, + kiapi::common::types::Kiid { + value: "power-kiid".to_string(), + }, + ], + path_human_readable: "/power".to_string(), + }, + ), + ), + }], + }; + return Some(reply_with(builders::pack_any( + &response, + "kiapi.common.commands.GetOpenDocumentsResponse", + ))); + } + return Some(unsupported_response( + kiapi::common::ApiStatusCode::AsUnhandled, + )); + } + panic!("unexpected command {}", message.type_url); + }); + + let state = KiCadIpcClient::new(&mock.url) + .observe_editor_state() + .expect("observe editor state"); + assert_eq!(state.kicad_version.full_version, "10.0.5"); + assert_eq!(state.evidence_source, "kicad_ipc"); + assert_eq!(state.active_editor, None); + assert_eq!(state.active_document, None); + assert_eq!(state.active_sheet_instance, None); + + let schematic = &state.editors[0]; + assert_eq!(schematic.editor, konnect_ipc::IpcEditorKind::Schematic); + assert!(schematic.addressable); + assert_eq!(schematic.documents.len(), 1); + assert_eq!(schematic.documents[0].document_path, None); + assert_eq!( + schematic.documents[0] + .sheet_instance_path + .as_ref() + .expect("sheet path") + .kiids, + ["root-kiid", "power-kiid"] + ); + assert_eq!( + schematic.capabilities.read_selection.availability, + konnect_ipc::IpcCapabilityAvailability::Available + ); + assert_eq!( + schematic.capabilities.observe_active_context.availability, + konnect_ipc::IpcCapabilityAvailability::Unsupported + ); + + let pcb = &state.editors[1]; + assert_eq!(pcb.editor, konnect_ipc::IpcEditorKind::Pcb); + assert!(!pcb.addressable); + assert!(pcb.documents.is_empty()); + assert!(pcb + .unavailable_reason + .as_deref() + .is_some_and(|reason| reason.contains("AS_UNHANDLED"))); +} + +#[test] +fn handled_empty_document_sets_are_not_inferred_as_closed_frames() { + let mock = spawn_mock(|request| { + let message = request.message.expect("request must pack a command"); + if message.type_url.ends_with("GetVersion") { + return Some(version_response()); + } + if message.type_url.ends_with("GetOpenDocuments") { + let response = kiapi::common::commands::GetOpenDocumentsResponse { documents: vec![] }; + return Some(reply_with(builders::pack_any( + &response, + "kiapi.common.commands.GetOpenDocumentsResponse", + ))); + } + panic!("unexpected command {}", message.type_url); + }); + + let state = KiCadIpcClient::new(&mock.url) + .observe_editor_state() + .expect("observe editor state"); + assert!(state + .editors + .iter() + .all(|editor| editor.addressable && editor.documents.is_empty())); +} + +#[test] +fn malformed_cross_type_document_identity_is_typed_and_never_retargeted() { + let mock = spawn_mock(|request| { + let message = request.message.expect("request must pack a command"); + if message.type_url.ends_with("GetVersion") { + return Some(version_response()); + } + if message.type_url.ends_with("GetOpenDocuments") { + let response = kiapi::common::commands::GetOpenDocumentsResponse { + documents: vec![kiapi::common::types::DocumentSpecifier { + r#type: kiapi::common::types::DocumentType::DoctypePcb as i32, + project: None, + identifier: Some( + kiapi::common::types::document_specifier::Identifier::BoardFilename( + "wrong.kicad_pcb".to_string(), + ), + ), + }], + }; + return Some(reply_with(builders::pack_any( + &response, + "kiapi.common.commands.GetOpenDocumentsResponse", + ))); + } + panic!("unexpected command {}", message.type_url); + }); + + let error = KiCadIpcClient::new(&mock.url) + .observe_editor_state() + .expect_err("cross-type document must refuse"); + assert!(error + .chain() + .any(|cause| cause.is::())); +} + +#[test] +fn unsupported_status_is_typed_without_changing_existing_rejection_classification() { + let mock = spawn_mock(|request| { + let message = request.message.expect("request must pack a command"); + assert!(message.type_url.ends_with("GetOpenDocuments")); + Some(unsupported_response( + kiapi::common::ApiStatusCode::AsUnimplemented, + )) + }); + let error = KiCadIpcClient::new(&mock.url) + .get_open_documents_for(konnect_ipc::IpcEditorKind::Pcb) + .expect_err("unsupported command must fail"); + let status = konnect_ipc::ApiStatusError::from_error(&error).expect("typed status"); + assert!(status.is_unsupported()); + assert_eq!(status.code_name, "AS_UNIMPLEMENTED"); + assert!(matches!( + konnect_ipc::IpcFailure::from_error(error), + konnect_ipc::IpcFailure::Rejected(_) + )); +} + #[test] fn save_document_to_string_targets_the_named_open_board() { let mock = spawn_mock(|request| { diff --git a/docs/KICAD_INTEGRATION.md b/docs/KICAD_INTEGRATION.md index 8a9cec95..aa36ef26 100644 --- a/docs/KICAD_INTEGRATION.md +++ b/docs/KICAD_INTEGRATION.md @@ -36,6 +36,22 @@ Closed-board move, rotate, and flip in `tools/pcb_components.rs` are narrowly scoped exceptions with explicit geometry checks. They are not a general license to edit a live board file. +### Editor observation + +`konnect-ipc::KiCadIpcClient::observe_editor_state` queries the running KiCad +version and schematic/PCB `GetOpenDocuments` surfaces on the configured +endpoint. `tools/editor_navigation.rs` exposes that typed observation through +the provisional `editor_navigation` toolset while design issue #395 is under +review. + +The observation preserves project and `DocumentSpecifier` sheet/board identity +and labels its evidence as live IPC. KiCad 10 has no stable typed query for the +foreground frame, active document, or active schematic sheet, so those fields +remain unavailable; Konnect does not infer active state from open-document +order. The same capability record reports stable typed activation and reveal +as unsupported rather than routing callers through arbitrary `RunAction` +strings. + ## Schematic-To-Board Sync `update_pcb_from_schematic` in `tools/pcb_sync.rs` is live-IPC-only. It uses diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index c3a9ba6b..ca69ee90 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -265,7 +265,7 @@ callable tools, the fix is to make the *first* listing complete: ``` in `konnect.toml` in the working directory, or a `settings.json` beside the binary. Every toolset is then loaded at -startup, so `tools/list` carries all 228 tools from the first call. +startup, so `tools/list` carries all 229 tools from the first call. It is off by default because it costs what the router exists to save: roughly 25K tokens per listing instead of ~2K. Turn it on only if your client needs it. diff --git a/packaging/metadata.json b/packaging/metadata.json index e8f7c9a4..046340cd 100644 --- a/packaging/metadata.json +++ b/packaging/metadata.json @@ -1,8 +1,8 @@ { "$schema": "https://go.kicad.org/pcm/schemas/v1", "name": "Konnect", - "description": "AI-assisted PCB design via the Model Context Protocol. Enables Claude and other AI assistants to design schematics and PCBs with 221 tools organized into on-demand toolsets.", - "description_full": "Konnect exposes a complete set of KiCAD design tools to AI assistants via the Model Context Protocol (MCP). It supports schematic editing, PCB layout, local Freerouting MCP routing, library management, JLCPCB part search, ERC/DRC, design review audits, and full export pipelines. Tools are organized into 20 toolsets loaded on demand so the AI only sees relevant tools at once.", + "description": "AI-assisted PCB design via the Model Context Protocol. Enables Claude and other AI assistants to design schematics and PCBs with 222 tools organized into on-demand toolsets.", + "description_full": "Konnect exposes a complete set of KiCAD design tools to AI assistants via the Model Context Protocol (MCP). It supports schematic editing, PCB layout, local Freerouting MCP routing, library management, JLCPCB part search, ERC/DRC, design review audits, and full export pipelines. Tools are organized into 21 toolsets loaded on demand so the AI only sees relevant tools at once.", "identifier": "com.github.mixelpixx.konnect", "type": "plugin", "author": { diff --git a/plugin/plugin.json b/plugin/plugin.json index 9cf62a70..b3196e87 100644 --- a/plugin/plugin.json +++ b/plugin/plugin.json @@ -1,7 +1,7 @@ { "identifier": "com.github.mixelpixx.konnect", "name": "Konnect", - "description": "AI-assisted PCB design via the Model Context Protocol. 221 tools for schematic editing, PCB layout, routing, design review, and manufacturing export.", + "description": "AI-assisted PCB design via the Model Context Protocol. 222 tools for schematic editing, PCB layout, routing, design review, and manufacturing export.", "runtime": { "type": "exec" }, diff --git a/tool-directory.md b/tool-directory.md index b4fcc532..d5282d1f 100644 --- a/tool-directory.md +++ b/tool-directory.md @@ -12,8 +12,8 @@ Compatibility notes for removed or narrowed arguments are recorded in ## Overview -- **20 toolsets** organized into 10 categories -- **221 registered tools** + **7 always-visible meta-tools** = **228 total** +- **21 toolsets** organized into 10 categories +- **222 registered tools** + **7 always-visible meta-tools** = **229 total** - **Discovery pattern**: the server pre-loads only the **starter kit** (`project`, `config`) so baseline `tools/list` costs ~2K tokens instead of ~23K. The LLM reads `list_toolboxes` → calls `load_toolset(name)` to expose additional tools on demand; `unload_toolset(name)` prunes them. `tools/list_changed` is notified on every mutation. If the LLM calls a tool whose toolset isn't loaded, the error names the owning toolset so recovery is a single `load_toolset` hop. `load_toolset` also accepts an array of names to load several toolsets with a single `tools/list` refresh. - **Observability**: every `tools/call` is recorded — ring buffer of the last 100 calls + per-tool counters + JSONL at `/logs/calls.jsonl`. The LLM self-diagnoses via `get_recent_calls` and `server_stats`. @@ -25,7 +25,7 @@ Seven tools, grouped into *discovery/routing*, *observability*, and *runtime dia | Tool | Purpose | |------|---------| -| `list_toolboxes` | List all 20 toolsets with category, tool count, and whether each is currently loaded. The LLM's starting point. | +| `list_toolboxes` | List all 21 toolsets with category, tool count, and whether each is currently loaded. The LLM's starting point. | | `load_toolset` | Load a toolset by name to expose its tools in `tools/list`. Returns the list of tools added. | | `unload_toolset` | Unload a toolset to prune its tools from `tools/list`. Use when switching tasks to keep context small. | | `get_active_toolsets` | Return the currently loaded toolsets and how many tools each provides. | @@ -61,6 +61,14 @@ Seven tools, grouped into *discovery/routing*, *observability*, and *runtime dia | `snapshot_project` | Export the schematic and PCB to PDF as a timestamped snapshot/checkpoint. Useful before major edits. | | `open_schematic_viewer` | Launch the live schematic viewer (SVG with auto-refresh on file change). Use after placing components so the user can see changes in real time. | +### `editor_navigation` · 1 tool +**Purpose:** Observe and semantically navigate exact KiCad editor, document, sheet, selection, and cross-probe context. +**Source:** [`crates/konnect-core/src/tools/editor_navigation.rs`](crates/konnect-core/src/tools/editor_navigation.rs) + +| Tool | Description | +|------|-------------| +| `get_editor_state` | Observe the configured KiCad IPC endpoint's running version, addressable schematic/PCB editors, exact open document identities, capability availability, and explicit active-context limitations. | + --- ## Schematic From 13b2219e0aee009ed6534390ef6f04a85e4a98df Mon Sep 17 00:00:00 2001 From: dubesinhower Date: Sun, 30 Aug 2026 13:28:53 -0400 Subject: [PATCH 13/16] feat(navigation): observe exact editor selection --- DEV.md | 6 +- README.md | 4 +- crates/konnect-core/src/mcp/error.rs | 21 + crates/konnect-core/src/router/registry.rs | 2 +- .../src/tools/editor_navigation.rs | 366 +++++++++++++++++- crates/konnect-ipc/build.rs | 1 + crates/konnect-ipc/src/client.rs | 352 +++++++++++++++++ crates/konnect-ipc/src/gen.rs | 6 + crates/konnect-ipc/src/types.rs | 64 +++ crates/konnect-ipc/tests/mock_server_test.rs | 282 +++++++++++++- docs/KICAD_INTEGRATION.md | 10 + docs/TROUBLESHOOTING.md | 2 +- packaging/metadata.json | 2 +- plugin/plugin.json | 2 +- tool-directory.md | 5 +- 15 files changed, 1099 insertions(+), 26 deletions(-) diff --git a/DEV.md b/DEV.md index 0880fa1a..f9f33cf7 100644 --- a/DEV.md +++ b/DEV.md @@ -316,7 +316,7 @@ Source: [`crates/konnect-core/src/observability.rs`](crates/konnect-core/src/obs ## Tool Routing (Starter Kit + On-Demand Loading) -The server does NOT expose all 222 tools (229 total with the 7 meta-tools) in `tools/list` by default — that would cost ~23K tokens of context on every listing. Instead: +The server does NOT expose all 223 tools (230 total with the 7 meta-tools) in `tools/list` by default — that would cost ~23K tokens of context on every listing. Instead: - **Startup**: only `STARTER_KIT` toolsets are pre-loaded (see `router/registry.rs::STARTER_KIT`). Currently: `project`, `config`. Combined with the 7 meta-tools, baseline `tools/list` is 21 tools ≈ 2K tokens. - **On demand**: the LLM reads `list_toolboxes` → calls `load_toolset(name)` to expose a toolset's tools in subsequent `tools/list` responses. `unload_toolset(name)` prunes them when the task shifts. @@ -391,9 +391,9 @@ convention for other `kicad-cli`-calling code. ## Current Stats -- **21 toolsets, 222 tools** + 7 meta-tools (4 routing + 2 observability + 1 runtime diagnostic — see `tool-directory.md`) +- **21 toolsets, 223 tools** + 7 meta-tools (4 routing + 2 observability + 1 runtime diagnostic — see `tool-directory.md`) - Baseline `tools/list`: 21 tools / ~2K tokens (starter kit + meta-tools) -- Full-catalog `tools/list` (all loaded): 229 tools (222 registered + 7 meta) / ~25K tokens +- Full-catalog `tools/list` (all loaded): 230 tools (223 registered + 7 meta) / ~25K tokens - **0 IPC stubs** (all protobuf methods implemented) - **0 unimplemented tools** - **Specctra DSN/SES are PCB-editor operations**, not `kicad-cli` commands. diff --git a/README.md b/README.md index ef4bf323..a4f89f32 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Rust binary — that lets Claude and other AI assistants design schematics and PCBs through the [Model Context Protocol](https://modelcontextprotocol.io) (MCP). -**222 tools across 21 on-demand toolsets.** Schematic capture, PCB layout and +**223 tools across 21 on-demand toolsets.** Schematic capture, PCB layout and routing, ERC/DRC, design-review audits, JLCPCB part search, reference circuits, and a full manufacturing export pipeline — with bundled skills and agents that teach Claude KiCAD conventions out of the box. @@ -70,7 +70,7 @@ through its own S-expression engine with atomic writes (write, fsync, rename), U preservation, and round-trip tests — no third-party schematic library with known gaps, no text-manipulation workarounds. -**Context economy is a feature.** Exposing all 222 tools to an LLM costs roughly 23K +**Context economy is a feature.** Exposing all 223 tools to an LLM costs roughly 23K tokens of context on every listing. Konnect's router loads a starter kit (~2K tokens) and lets the model pull in toolsets on demand — plus built-in observability (`get_recent_calls`, `server_stats`, JSONL call logs) so the model can diagnose its diff --git a/crates/konnect-core/src/mcp/error.rs b/crates/konnect-core/src/mcp/error.rs index fd8f44d8..94cad6b1 100644 --- a/crates/konnect-core/src/mcp/error.rs +++ b/crates/konnect-core/src/mcp/error.rs @@ -63,6 +63,17 @@ pub enum ToolErrorKind { requested: String, open_documents: Vec, }, + /// No open document belongs to the explicitly requested project. + WrongProject { + requested: String, + open_projects: Vec, + }, + /// The requested schematic hierarchy instance is not the instance set + /// observed in the exact live schematic editor context. + WrongSheetInstance { + requested: String, + open_sheet_instances: Vec, + }, /// The caller named a target, but its observed editor or document state /// no longer agrees with the state required to mutate it safely. StaleTarget { target: String, reason: String }, @@ -96,6 +107,8 @@ impl ToolErrorKind { Self::Conflict { .. } => "conflict", Self::AmbiguousTarget { .. } => "ambiguous_target", Self::WrongDocument { .. } => "wrong_document", + Self::WrongProject { .. } => "wrong_project", + Self::WrongSheetInstance { .. } => "wrong_sheet_instance", Self::StaleTarget { .. } => "stale_target", Self::EditorUnavailable { .. } => "editor_unavailable", Self::UnsupportedCapability { .. } => "unsupported_capability", @@ -207,6 +220,14 @@ mod tests { requested: "p".into(), open_documents: vec!["a".into()], }, + ToolErrorKind::WrongProject { + requested: "p".into(), + open_projects: vec!["a".into()], + }, + ToolErrorKind::WrongSheetInstance { + requested: "/root/child".into(), + open_sheet_instances: vec!["/root/other".into()], + }, ToolErrorKind::StaleTarget { target: "p".into(), reason: "r".into(), diff --git a/crates/konnect-core/src/router/registry.rs b/crates/konnect-core/src/router/registry.rs index ef79231f..e92b6fcb 100644 --- a/crates/konnect-core/src/router/registry.rs +++ b/crates/konnect-core/src/router/registry.rs @@ -28,7 +28,7 @@ pub static ALL_TOOLSETS: &[ToolsetMeta] = &[ name: "editor_navigation", description: "Observe and semantically navigate exact KiCad editor, document, sheet, selection, and cross-probe context", category: "project", - tool_count: 1, + tool_count: 2, }, ToolsetMeta { name: "sch_components", diff --git a/crates/konnect-core/src/tools/editor_navigation.rs b/crates/konnect-core/src/tools/editor_navigation.rs index ad292b33..5b3f6e70 100644 --- a/crates/konnect-core/src/tools/editor_navigation.rs +++ b/crates/konnect-core/src/tools/editor_navigation.rs @@ -7,20 +7,47 @@ use crate::mcp::{error::ToolErrorKind, protocol::CallToolResult}; use crate::tool; -use crate::tools::{ToolContext, ToolDef}; +use crate::tools::{invalid_arg, opt_str, require_array, require_str, ToolContext, ToolDef}; +use konnect_ipc::{ + IpcEditorDocument, IpcEditorKind, IpcProjectIdentity, IpcSelectionObservationErrorKind, + IpcSheetInstancePath, +}; use serde_json::json; pub fn tools() -> Vec { - vec![tool!( - "get_editor_state", - "Observe the configured KiCad IPC endpoint: running KiCad version, addressable schematic and PCB editors, exact open document identities, and semantic navigation capabilities. Active editor/document/sheet fields are null when KiCad has no stable typed query; open-document order is never treated as active state.", - json!({ - "type": "object", - "properties": {}, - "required": [] - }), - |args, ctx| async move { handle_get_editor_state(args, ctx).await } - )] + vec![ + tool!( + "get_editor_state", + "Observe the configured KiCad IPC endpoint: running KiCad version, addressable schematic and PCB editors, exact open document identities, and semantic navigation capabilities. Active editor/document/sheet fields are null when KiCad has no stable typed query; open-document order is never treated as active state.", + json!({ + "type": "object", + "properties": {}, + "required": [] + }), + |args, ctx| async move { handle_get_editor_state(args, ctx).await } + ), + tool!( + "get_editor_selection", + "Read the current selection from one exact KiCad editor, project, document, and schematic sheet instance. Returns stable KIID/UUID identities and refuses stale, ambiguous, cross-project, cross-document, or unsupported selected state instead of retargeting.", + json!({ + "type": "object", + "properties": { + "editor": { "type": "string", "enum": ["schematic", "pcb"] }, + "project_name": { "type": "string", "description": "Exact project name returned by get_editor_state" }, + "project_path": { "type": "string", "description": "Exact project path returned by get_editor_state" }, + "document_path": { "type": "string", "description": "Exact PCB path; required only for editor=pcb" }, + "sheet_instance_path": { + "type": "array", + "items": { "type": "string" }, + "description": "Canonical root-to-leaf sheet KIIIDs; required only for editor=schematic" + }, + "sheet_path_human_readable": { "type": "string", "description": "Optional display path returned by get_editor_state; not used as identity" } + }, + "required": ["editor", "project_name", "project_path"] + }), + |args, ctx| async move { handle_get_editor_selection(args, ctx).await } + ), + ] } async fn handle_get_editor_state( @@ -96,6 +123,166 @@ fn editor_unavailable(reason: &str) -> CallToolResult { ) } +async fn handle_get_editor_selection( + args: &serde_json::Value, + ctx: &ToolContext, +) -> anyhow::Result { + let target = match parse_selection_target(args) { + Ok(target) => target, + Err(result) => return Ok(result), + }; + let address = ctx.config.ipc_address.clone(); + if address.is_empty() { + return Ok(editor_unavailable("no KiCad IPC endpoint is configured")); + } + let result = tokio::task::spawn_blocking(move || { + konnect_ipc::KiCadIpcClient::new(address).observe_selection(&target) + }) + .await?; + match result { + Ok(observation) => Ok(CallToolResult::json(&observation)), + Err(error) => Ok(selection_error_result(error)), + } +} + +fn parse_selection_target(args: &serde_json::Value) -> Result { + let editor = match require_str(args, "editor")? { + "schematic" => IpcEditorKind::Schematic, + "pcb" => IpcEditorKind::Pcb, + _ => return Err(invalid_arg("editor", "expected 'schematic' or 'pcb'")), + }; + let project_name = require_str(args, "project_name")?; + let project_path = require_str(args, "project_path")?; + if project_name.is_empty() { + return Err(invalid_arg("project_name", "must not be empty")); + } + if project_path.is_empty() { + return Err(invalid_arg("project_path", "must not be empty")); + } + let project = Some(IpcProjectIdentity { + name: project_name.to_string(), + path: project_path.to_string(), + }); + match editor { + IpcEditorKind::Pcb => { + let document_path = require_str(args, "document_path")?; + if document_path.is_empty() { + return Err(invalid_arg("document_path", "must not be empty")); + } + // Read the schematic-only arguments too so schema/handler drift + // checks can prove every advertised parameter is intentional. + if !args["sheet_instance_path"].is_null() + || !args["sheet_path_human_readable"].is_null() + { + return Err(invalid_arg( + "sheet_instance_path", + "schematic sheet identity is not valid for editor=pcb", + )); + } + Ok(IpcEditorDocument { + editor, + project, + document_path: Some(document_path.to_string()), + sheet_instance_path: None, + }) + } + IpcEditorKind::Schematic => { + if !args["document_path"].is_null() { + return Err(invalid_arg( + "document_path", + "PCB document paths are not schematic sheet identity", + )); + } + let ids = require_array(args, "sheet_instance_path")? + .iter() + .map(|value| value.as_str().map(str::to_string)) + .collect::>>() + .ok_or_else(|| { + invalid_arg("sheet_instance_path", "every entry must be a string") + })?; + if ids.is_empty() || ids.iter().any(String::is_empty) { + return Err(invalid_arg( + "sheet_instance_path", + "must contain non-empty root-to-leaf KIIIDs", + )); + } + Ok(IpcEditorDocument { + editor, + project, + document_path: None, + sheet_instance_path: Some(IpcSheetInstancePath { + kiids: ids, + human_readable: opt_str(args, "sheet_path_human_readable") + .unwrap_or("") + .to_string(), + }), + }) + } + } +} + +fn selection_error_result(error: anyhow::Error) -> CallToolResult { + if let Some(selection) = konnect_ipc::IpcSelectionObservationError::from_error(&error) { + let kind = match selection.kind { + IpcSelectionObservationErrorKind::WrongProject => ToolErrorKind::WrongProject { + requested: selection.requested.clone(), + open_projects: selection.candidates.clone(), + }, + IpcSelectionObservationErrorKind::WrongDocument => ToolErrorKind::WrongDocument { + requested: selection.requested.clone(), + open_documents: selection.candidates.clone(), + }, + IpcSelectionObservationErrorKind::WrongSheetInstance => { + ToolErrorKind::WrongSheetInstance { + requested: selection.requested.clone(), + open_sheet_instances: selection.candidates.clone(), + } + } + IpcSelectionObservationErrorKind::AmbiguousDocument => ToolErrorKind::AmbiguousTarget { + target: selection.requested.clone(), + candidates: selection.candidates.clone(), + }, + IpcSelectionObservationErrorKind::UnsupportedObjectType => { + ToolErrorKind::UnsupportedCapability { + capability: format!("decode selected object type {}", selection.requested), + kicad_version: None, + } + } + IpcSelectionObservationErrorKind::StaleEditorState + | IpcSelectionObservationErrorKind::MalformedSelectedObject => { + ToolErrorKind::StaleTarget { + target: selection.requested.clone(), + reason: selection.reason.clone(), + } + } + }; + return CallToolResult::error_kind(kind, selection.to_string()); + } + if let Some(status) = konnect_ipc::ApiStatusError::from_error(&error) { + if status.is_unsupported() { + return CallToolResult::error_kind( + ToolErrorKind::UnsupportedCapability { + capability: "selection_observation".to_string(), + kicad_version: None, + }, + "The running KiCad endpoint does not support typed selection observation.", + ); + } + } + match konnect_ipc::IpcFailure::from_error(error) { + konnect_ipc::IpcFailure::Unreachable(_) => { + editor_unavailable("the configured KiCad IPC endpoint is unreachable") + } + _ => CallToolResult::error_kind( + ToolErrorKind::StaleTarget { + target: "requested editor selection".to_string(), + reason: "KiCad did not return a complete typed selection readback".to_string(), + }, + "KiCad did not return a complete typed selection readback.", + ), + } +} + #[cfg(test)] mod tests { use super::*; @@ -173,12 +360,91 @@ mod tests { url } + fn spawn_selection_mock() -> String { + static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let url = format!( + "inproc://editor-selection-core-{}", + NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + ); + let socket = nng::Socket::new(nng::Protocol::Rep0).expect("mock socket"); + socket.listen(&url).expect("listen"); + std::thread::spawn(move || { + for _ in 0..3 { + let message = socket.recv().expect("request"); + let request = + kiapi::common::ApiRequest::decode(message.as_slice()).expect("decode request"); + let command = request.message.expect("command"); + let response_any = if command.type_url.ends_with("GetOpenDocuments") { + builders::pack_any( + &kiapi::common::commands::GetOpenDocumentsResponse { + documents: vec![kiapi::common::types::DocumentSpecifier { + r#type: kiapi::common::types::DocumentType::DoctypePcb as i32, + identifier: Some( + kiapi::common::types::document_specifier::Identifier::BoardFilename( + "navigation.kicad_pcb".to_string(), + ), + ), + project: Some(kiapi::common::types::ProjectSpecifier { + name: "navigation".to_string(), + path: r"C:\design".to_string(), + }), + }], + }, + "kiapi.common.commands.GetOpenDocumentsResponse", + ) + } else { + let footprint = kiapi::board::types::FootprintInstance { + id: Some(kiapi::common::types::Kiid { + value: "footprint-kiid".to_string(), + }), + ..Default::default() + }; + builders::pack_any( + &kiapi::common::commands::SelectionResponse { + items: vec![builders::pack_any( + &footprint, + "kiapi.board.types.FootprintInstance", + )], + }, + "kiapi.common.commands.SelectionResponse", + ) + }; + socket + .send(nng::Message::from( + kiapi::common::ApiResponse { + status: Some(kiapi::common::ApiResponseStatus { + status: kiapi::common::ApiStatusCode::AsOk as i32, + error_message: String::new(), + }), + header: None, + message: Some(response_any), + } + .encode_to_vec() + .as_slice(), + )) + .expect("response"); + } + }); + url + } + #[test] fn public_tool_is_read_only_and_takes_no_required_arguments() { let definitions = tools(); - assert_eq!(definitions.len(), 1); - assert_eq!(definitions[0].name, "get_editor_state"); - assert_eq!(definitions[0].input_schema["required"], json!([])); + assert_eq!(definitions.len(), 2); + let state = definitions + .iter() + .find(|tool| tool.name == "get_editor_state") + .expect("state tool"); + assert_eq!(state.input_schema["required"], json!([])); + let selection = definitions + .iter() + .find(|tool| tool.name == "get_editor_selection") + .expect("selection tool"); + assert_eq!( + selection.input_schema["required"], + json!(["editor", "project_name", "project_path"]) + ); } #[tokio::test] @@ -210,4 +476,76 @@ mod tests { assert!(body["active_document"].is_null()); assert!(body["active_sheet_instance"].is_null()); } + + #[tokio::test] + async fn public_selection_result_is_derived_from_exact_document_readback() { + let result = handle_get_editor_selection( + &json!({ + "editor": "pcb", + "project_name": "navigation", + "project_path": r"C:\design", + "document_path": r"C:\design\navigation.kicad_pcb" + }), + &context(spawn_selection_mock()), + ) + .await + .expect("handler result"); + assert!(!result.is_error); + let ToolContent::Text { text } = &result.content[0] else { + panic!("expected text result"); + }; + let body: serde_json::Value = serde_json::from_str(text).expect("json result"); + assert_eq!(body["editor"], "pcb"); + assert_eq!(body["selected_objects"][0]["kiid"], "footprint-kiid"); + assert_eq!( + body["evidence_source"], + "kicad_ipc_get_selection_with_document_readback" + ); + } + + #[test] + fn public_selection_parser_refuses_cross_editor_identity_fields() { + let pcb_with_sheet = parse_selection_target(&json!({ + "editor": "pcb", + "project_name": "navigation", + "project_path": r"C:\design", + "document_path": r"C:\design\navigation.kicad_pcb", + "sheet_instance_path": ["root"] + })) + .expect_err("PCB must not accept a schematic identity"); + assert_eq!( + extract_error_kind(&pcb_with_sheet).as_deref(), + Some("invalid_argument") + ); + + let schematic_with_board = parse_selection_target(&json!({ + "editor": "schematic", + "project_name": "navigation", + "project_path": r"C:\design", + "document_path": r"C:\design\navigation.kicad_sch", + "sheet_instance_path": ["root"] + })) + .expect_err("schematic must not accept a PCB path"); + assert_eq!( + extract_error_kind(&schematic_with_board).as_deref(), + Some("invalid_argument") + ); + } + + #[test] + fn wrong_sheet_instance_maps_to_the_public_structured_refusal() { + let result = selection_error_result(anyhow::Error::new( + konnect_ipc::IpcSelectionObservationError { + kind: IpcSelectionObservationErrorKind::WrongSheetInstance, + editor: IpcEditorKind::Schematic, + requested: "/root/requested".to_string(), + candidates: vec!["/root/other".to_string()], + reason: "wrong sheet".to_string(), + }, + )); + assert_eq!( + extract_error_kind(&result).as_deref(), + Some("wrong_sheet_instance") + ); + } } diff --git a/crates/konnect-ipc/build.rs b/crates/konnect-ipc/build.rs index dea72aa1..2682e124 100644 --- a/crates/konnect-ipc/build.rs +++ b/crates/konnect-ipc/build.rs @@ -110,6 +110,7 @@ fn main() -> Result<(), Box> { "proto/board/board.proto", "proto/board/board_commands.proto", "proto/board/board_types.proto", + "proto/schematic/schematic_types.proto", ]; let mut includes: Vec = vec![PathBuf::from("proto/")]; diff --git a/crates/konnect-ipc/src/client.rs b/crates/konnect-ipc/src/client.rs index b8e58b49..1950820a 100644 --- a/crates/konnect-ipc/src/client.rs +++ b/crates/konnect-ipc/src/client.rs @@ -717,6 +717,124 @@ impl KiCadIpcClient { }) } + /// Read the selection for one exact live editor/document/sheet context. + /// + /// The requested identity is matched against `GetOpenDocuments` before + /// and after `GetSelection`. KiCad's `SelectionResponse` has no response + /// header, so this bounded document readback is the freshness boundary: + /// a disappeared or retargeted document is never reported as a valid + /// selection from the caller's context. + pub fn observe_selection( + &self, + requested: &IpcEditorDocument, + ) -> Result { + let before = self.resolve_selection_document(requested)?; + let command = kiapi::common::commands::GetSelection { + header: Some(header_for(before.clone())), + types: Vec::new(), + }; + let response = unpack_required::( + self.send_command(&command, "kiapi.common.commands.GetSelection")?, + "GetSelection", + )?; + let selected_objects = response + .items + .iter() + .map(|item| decode_selected_object(requested.editor, item)) + .collect::>>()?; + + let after = self + .resolve_selection_document(requested) + .map_err(|error| { + let (candidates, reason) = IpcSelectionObservationError::from_error(&error) + .map(|selection| (selection.candidates.clone(), selection.reason.clone())) + .unwrap_or_else(|| (Vec::new(), error.to_string())); + anyhow::Error::new(IpcSelectionObservationError { + kind: IpcSelectionObservationErrorKind::StaleEditorState, + editor: requested.editor, + requested: editor_document_label(requested), + candidates, + reason: format!("document context changed during selection readback: {reason}"), + }) + })?; + if before != after { + return Err(anyhow::Error::new(IpcSelectionObservationError { + kind: IpcSelectionObservationErrorKind::StaleEditorState, + editor: requested.editor, + requested: editor_document_label(requested), + candidates: vec![document_specifier_label(&after)], + reason: "document identity changed during selection readback".to_string(), + })); + } + + Ok(IpcSelectionObservation { + project: requested.project.clone(), + document: requested.clone(), + editor: requested.editor, + sheet_instance_path: requested.sheet_instance_path.clone(), + selected_objects, + evidence_source: "kicad_ipc_get_selection_with_document_readback".to_string(), + }) + } + + fn resolve_selection_document( + &self, + requested: &IpcEditorDocument, + ) -> Result { + let raw_documents = self.get_open_documents_for(requested.editor)?; + let mut documents = Vec::with_capacity(raw_documents.len()); + for raw in raw_documents { + let observed = editor_document_from_specifier(requested.editor, raw.clone())?; + documents.push((raw, observed)); + } + + let candidate_labels = documents + .iter() + .map(|(_, document)| editor_document_label(document)) + .collect::>(); + let same_project = documents + .iter() + .filter(|(_, document)| document.project == requested.project) + .collect::>(); + if same_project.is_empty() && !documents.is_empty() { + return Err(selection_target_error( + IpcSelectionObservationErrorKind::WrongProject, + requested, + candidate_labels, + "no open document belongs to the requested project", + )); + } + + let exact = same_project + .into_iter() + .filter(|(_, document)| selection_document_identity_matches(requested, document)) + .collect::>(); + if exact.is_empty() { + let kind = match requested.editor { + IpcEditorKind::Schematic => IpcSelectionObservationErrorKind::WrongSheetInstance, + IpcEditorKind::Pcb => IpcSelectionObservationErrorKind::WrongDocument, + }; + return Err(selection_target_error( + kind, + requested, + candidate_labels, + "the exact requested document or sheet instance is not open", + )); + } + if exact.len() != 1 { + return Err(selection_target_error( + IpcSelectionObservationErrorKind::AmbiguousDocument, + requested, + exact + .iter() + .map(|(_, document)| editor_document_label(document)) + .collect(), + "KiCad returned duplicate exact document identities", + )); + } + Ok(exact[0].0.clone()) + } + fn observe_editor( &self, editor: IpcEditorKind, @@ -2925,6 +3043,240 @@ fn editor_document_from_specifier( }) } +fn selection_document_identity_matches( + requested: &IpcEditorDocument, + observed: &IpcEditorDocument, +) -> bool { + if requested.editor != observed.editor || requested.project != observed.project { + return false; + } + match requested.editor { + IpcEditorKind::Pcb => { + requested.document_path.is_some() + && requested.document_path == observed.document_path + && requested.sheet_instance_path.is_none() + } + IpcEditorKind::Schematic => { + requested.document_path.is_none() + && requested + .sheet_instance_path + .as_ref() + .is_some_and(|requested_path| { + observed + .sheet_instance_path + .as_ref() + .is_some_and(|observed_path| { + requested_path.kiids == observed_path.kiids + }) + }) + } + } +} + +fn selection_target_error( + kind: IpcSelectionObservationErrorKind, + requested: &IpcEditorDocument, + candidates: Vec, + reason: &str, +) -> anyhow::Error { + anyhow::Error::new(IpcSelectionObservationError { + kind, + editor: requested.editor, + requested: editor_document_label(requested), + candidates, + reason: reason.to_string(), + }) +} + +fn editor_document_label(document: &IpcEditorDocument) -> String { + let project = document + .project + .as_ref() + .map(|project| format!("{} at {}", project.name, project.path)) + .unwrap_or_else(|| "standalone project".to_string()); + match document.editor { + IpcEditorKind::Pcb => format!( + "PCB {} in {project}", + document + .document_path + .as_deref() + .unwrap_or("") + ), + IpcEditorKind::Schematic => format!( + "schematic sheet {} in {project}", + document + .sheet_instance_path + .as_ref() + .map(|path| { + if path.human_readable.is_empty() { + path.kiids.join("/") + } else { + path.human_readable.clone() + } + }) + .unwrap_or_else(|| "".to_string()) + ), + } +} + +fn document_specifier_label(document: &kiapi::common::types::DocumentSpecifier) -> String { + let editor = match kiapi::common::types::DocumentType::try_from(document.r#type) { + Ok(kiapi::common::types::DocumentType::DoctypeSchematic) => IpcEditorKind::Schematic, + _ => IpcEditorKind::Pcb, + }; + editor_document_from_specifier(editor, document.clone()) + .map(|document| editor_document_label(&document)) + .unwrap_or_else(|_| "malformed live document".to_string()) +} + +fn decode_selected_object( + editor: IpcEditorKind, + item: &prost_types::Any, +) -> Result { + let protocol_type = crate::builders::any_type_name(item); + macro_rules! selected { + ($message:ty, $kind:literal, $id:expr) => {{ + let decoded: $message = unpack_any(item).map_err(|error| { + malformed_selected_object(editor, protocol_type, format!("decode failed: {error}")) + })?; + selected_object_from_id(editor, protocol_type, $kind, $id(&decoded)) + }}; + } + + match protocol_type { + "kiapi.board.types.FootprintInstance" => selected!( + kiapi::board::types::FootprintInstance, + "pcb_footprint", + |value: &kiapi::board::types::FootprintInstance| value.id.clone() + ), + "kiapi.board.types.Pad" => selected!( + kiapi::board::types::Pad, + "pcb_pad", + |value: &kiapi::board::types::Pad| value.id.clone() + ), + "kiapi.board.types.BoardGraphicShape" => selected!( + kiapi::board::types::BoardGraphicShape, + "pcb_shape", + |value: &kiapi::board::types::BoardGraphicShape| value.id.clone() + ), + "kiapi.board.types.BoardText" => selected!( + kiapi::board::types::BoardText, + "pcb_text", + |value: &kiapi::board::types::BoardText| value.id.clone() + ), + "kiapi.board.types.BoardTextBox" => selected!( + kiapi::board::types::BoardTextBox, + "pcb_text_box", + |value: &kiapi::board::types::BoardTextBox| value.id.clone() + ), + "kiapi.board.types.Track" => selected!( + kiapi::board::types::Track, + "pcb_trace", + |value: &kiapi::board::types::Track| value.id.clone() + ), + "kiapi.board.types.Via" => selected!( + kiapi::board::types::Via, + "pcb_via", + |value: &kiapi::board::types::Via| value.id.clone() + ), + "kiapi.board.types.Arc" => selected!( + kiapi::board::types::Arc, + "pcb_arc", + |value: &kiapi::board::types::Arc| value.id.clone() + ), + "kiapi.board.types.Dimension" => selected!( + kiapi::board::types::Dimension, + "pcb_dimension", + |value: &kiapi::board::types::Dimension| value.id.clone() + ), + "kiapi.board.types.Zone" => selected!( + kiapi::board::types::Zone, + "pcb_zone", + |value: &kiapi::board::types::Zone| value.id.clone() + ), + "kiapi.board.types.Group" => selected!( + kiapi::board::types::Group, + "pcb_group", + |value: &kiapi::board::types::Group| value.id.clone() + ), + "kiapi.board.types.Field" => selected!( + kiapi::board::types::Field, + "pcb_field", + |value: &kiapi::board::types::Field| value + .text + .as_ref() + .and_then(|text| text.id.clone()) + ), + "kiapi.schematic.types.Line" => selected!( + kiapi::schematic::types::Line, + "schematic_line", + |value: &kiapi::schematic::types::Line| value.id.clone() + ), + "kiapi.schematic.types.LocalLabel" => selected!( + kiapi::schematic::types::LocalLabel, + "schematic_label", + |value: &kiapi::schematic::types::LocalLabel| value.id.clone() + ), + "kiapi.schematic.types.GlobalLabel" => selected!( + kiapi::schematic::types::GlobalLabel, + "schematic_global_label", + |value: &kiapi::schematic::types::GlobalLabel| value.id.clone() + ), + "kiapi.schematic.types.HierarchicalLabel" => selected!( + kiapi::schematic::types::HierarchicalLabel, + "schematic_hierarchical_label", + |value: &kiapi::schematic::types::HierarchicalLabel| value.id.clone() + ), + "kiapi.schematic.types.DirectiveLabel" => selected!( + kiapi::schematic::types::DirectiveLabel, + "schematic_directive_label", + |value: &kiapi::schematic::types::DirectiveLabel| value.id.clone() + ), + _ => Err(anyhow::Error::new(IpcSelectionObservationError { + kind: IpcSelectionObservationErrorKind::UnsupportedObjectType, + editor, + requested: protocol_type.to_string(), + candidates: Vec::new(), + reason: "the bundled stable KiCad protocol cannot decode this selected object type" + .to_string(), + })), + } +} + +fn selected_object_from_id( + editor: IpcEditorKind, + protocol_type: &str, + object_type: &str, + id: Option, +) -> Result { + let Some(id) = id.filter(|id| !id.value.is_empty()) else { + return Err(malformed_selected_object( + editor, + protocol_type, + "selected object has no stable KIID".to_string(), + )); + }; + Ok(IpcSelectedObject { + kiid: id.value, + object_type: object_type.to_string(), + protocol_type: protocol_type.to_string(), + }) +} + +fn malformed_selected_object( + editor: IpcEditorKind, + protocol_type: &str, + reason: String, +) -> anyhow::Error { + anyhow::Error::new(IpcSelectionObservationError { + kind: IpcSelectionObservationErrorKind::MalformedSelectedObject, + editor, + requested: protocol_type.to_string(), + candidates: Vec::new(), + reason, + }) +} + fn editor_capabilities( editor: IpcEditorKind, version: &IpcKiCadVersion, diff --git a/crates/konnect-ipc/src/gen.rs b/crates/konnect-ipc/src/gen.rs index df308d8b..d71fef0d 100644 --- a/crates/konnect-ipc/src/gen.rs +++ b/crates/konnect-ipc/src/gen.rs @@ -29,4 +29,10 @@ pub mod kiapi { include!(concat!(env!("OUT_DIR"), "/kiapi.board.commands.rs")); } } + + pub mod schematic { + pub mod types { + include!(concat!(env!("OUT_DIR"), "/kiapi.schematic.types.rs")); + } + } } diff --git a/crates/konnect-ipc/src/types.rs b/crates/konnect-ipc/src/types.rs index 3d58ad3e..23884413 100644 --- a/crates/konnect-ipc/src/types.rs +++ b/crates/konnect-ipc/src/types.rs @@ -110,6 +110,70 @@ pub struct IpcEditorStateObservation { pub limitations: Vec, } +/// One selected object observed from the exact requested live document. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct IpcSelectedObject { + /// Stable KiCad object identifier (KIID/UUID), never a display reference. + pub kiid: String, + /// Semantic object kind derived from the exact protobuf type URL. + pub object_type: String, + /// Full protobuf type carried by KiCad, retained as decoding evidence. + pub protocol_type: String, +} + +/// Selection readback bound to one exact live editor/document/sheet context. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct IpcSelectionObservation { + pub project: Option, + pub document: IpcEditorDocument, + pub editor: IpcEditorKind, + pub sheet_instance_path: Option, + pub selected_objects: Vec, + pub evidence_source: String, +} + +/// Stable classification for a fail-closed selection observation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IpcSelectionObservationErrorKind { + WrongProject, + WrongDocument, + WrongSheetInstance, + AmbiguousDocument, + StaleEditorState, + UnsupportedObjectType, + MalformedSelectedObject, +} + +/// A selection could not be attributed to the exact requested live context. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IpcSelectionObservationError { + pub kind: IpcSelectionObservationErrorKind, + pub editor: IpcEditorKind, + pub requested: String, + pub candidates: Vec, + pub reason: String, +} + +impl IpcSelectionObservationError { + pub fn from_error(error: &anyhow::Error) -> Option<&Self> { + error.chain().find_map(|cause| cause.downcast_ref::()) + } +} + +impl std::fmt::Display for IpcSelectionObservationError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + formatter, + "cannot observe {} selection for {}: {}", + self.editor.as_str(), + self.requested, + self.reason + ) + } +} + +impl std::error::Error for IpcSelectionObservationError {} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct IpcVector2 { pub x: f64, diff --git a/crates/konnect-ipc/tests/mock_server_test.rs b/crates/konnect-ipc/tests/mock_server_test.rs index 7bc3977f..9541a6bb 100644 --- a/crates/konnect-ipc/tests/mock_server_test.rs +++ b/crates/konnect-ipc/tests/mock_server_test.rs @@ -7,7 +7,10 @@ use konnect_ipc::builders; use konnect_ipc::gen::kiapi; -use konnect_ipc::KiCadIpcClient; +use konnect_ipc::{ + IpcEditorDocument, IpcEditorKind, IpcProjectIdentity, IpcSelectionObservationError, + IpcSelectionObservationErrorKind, IpcSheetInstancePath, KiCadIpcClient, +}; use nng::options::Options; use prost::Message; use std::sync::{Arc, Mutex}; @@ -1833,3 +1836,280 @@ fn deletes_target_the_named_board_among_several_open() { "a delete must act on the requested board, not the first open one" ); } + +fn navigation_project() -> kiapi::common::types::ProjectSpecifier { + kiapi::common::types::ProjectSpecifier { + name: "navigation".to_string(), + path: r"C:\design".to_string(), + } +} + +fn navigation_board_document() -> kiapi::common::types::DocumentSpecifier { + kiapi::common::types::DocumentSpecifier { + r#type: kiapi::common::types::DocumentType::DoctypePcb as i32, + identifier: Some( + kiapi::common::types::document_specifier::Identifier::BoardFilename( + "navigation.kicad_pcb".to_string(), + ), + ), + project: Some(navigation_project()), + } +} + +fn navigation_sheet_document(ids: &[&str]) -> kiapi::common::types::DocumentSpecifier { + kiapi::common::types::DocumentSpecifier { + r#type: kiapi::common::types::DocumentType::DoctypeSchematic as i32, + identifier: Some( + kiapi::common::types::document_specifier::Identifier::SheetPath( + kiapi::common::types::SheetPath { + path: ids.iter().map(|id| kiid(id)).collect(), + path_human_readable: "/child".to_string(), + }, + ), + ), + project: Some(navigation_project()), + } +} + +fn open_navigation_documents_response( + documents: Vec, +) -> kiapi::common::ApiResponse { + reply_with(builders::pack_any( + &kiapi::common::commands::GetOpenDocumentsResponse { documents }, + "kiapi.common.commands.GetOpenDocumentsResponse", + )) +} + +fn selection_response(items: Vec) -> kiapi::common::ApiResponse { + reply_with(builders::pack_any( + &kiapi::common::commands::SelectionResponse { items }, + "kiapi.common.commands.SelectionResponse", + )) +} + +fn navigation_project_identity() -> Option { + Some(IpcProjectIdentity { + name: "navigation".to_string(), + path: r"C:\design".to_string(), + }) +} + +fn navigation_board_target() -> IpcEditorDocument { + IpcEditorDocument { + editor: IpcEditorKind::Pcb, + project: navigation_project_identity(), + document_path: Some(r"C:\design\navigation.kicad_pcb".to_string()), + sheet_instance_path: None, + } +} + +fn navigation_sheet_target(ids: &[&str]) -> IpcEditorDocument { + IpcEditorDocument { + editor: IpcEditorKind::Schematic, + project: navigation_project_identity(), + document_path: None, + sheet_instance_path: Some(IpcSheetInstancePath { + kiids: ids.iter().map(|id| (*id).to_string()).collect(), + human_readable: "/child".to_string(), + }), + } +} + +fn selection_kind(error: &anyhow::Error) -> IpcSelectionObservationErrorKind { + IpcSelectionObservationError::from_error(error) + .expect("typed selection observation error") + .kind +} + +#[test] +fn selection_observation_is_bound_to_the_exact_board_and_returns_stable_kiids() { + let captured = Arc::new(Mutex::new(None)); + let captured_in_mock = captured.clone(); + let footprint = kiapi::board::types::FootprintInstance { + id: Some(kiid("footprint-kiid")), + ..Default::default() + }; + let selected = builders::pack_any(&footprint, "kiapi.board.types.FootprintInstance"); + let mock = spawn_mock(move |request| { + let message = request.message.expect("command"); + if message.type_url.ends_with("GetOpenDocuments") { + return Some(open_navigation_documents_response(vec![ + navigation_board_document(), + ])); + } + if message.type_url.ends_with("GetSelection") { + let request = kiapi::common::commands::GetSelection::decode(message.value.as_slice()) + .expect("selection request"); + record_doc(&captured_in_mock, &request.header); + return Some(selection_response(vec![selected.clone()])); + } + panic!("unexpected request {}", message.type_url); + }); + + let observation = KiCadIpcClient::new(&mock.url) + .observe_selection(&navigation_board_target()) + .expect("selection observation"); + assert_eq!(observation.editor, IpcEditorKind::Pcb); + assert_eq!(observation.project, navigation_project_identity()); + assert_eq!(observation.selected_objects.len(), 1); + assert_eq!(observation.selected_objects[0].kiid, "footprint-kiid"); + assert_eq!(observation.selected_objects[0].object_type, "pcb_footprint"); + assert_eq!( + captured.lock().unwrap().as_deref(), + Some("navigation.kicad_pcb") + ); +} + +#[test] +fn schematic_selection_preserves_the_exact_sheet_instance_and_label_kiid() { + let label = kiapi::schematic::types::LocalLabel { + id: Some(kiid("label-kiid")), + ..Default::default() + }; + let selected = builders::pack_any(&label, "kiapi.schematic.types.LocalLabel"); + let mock = spawn_mock(move |request| { + let message = request.message.expect("command"); + if message.type_url.ends_with("GetOpenDocuments") { + return Some(open_navigation_documents_response(vec![ + navigation_sheet_document(&["root", "child"]), + ])); + } + if message.type_url.ends_with("GetSelection") { + return Some(selection_response(vec![selected.clone()])); + } + panic!("unexpected request {}", message.type_url); + }); + let target = navigation_sheet_target(&["root", "child"]); + let observation = KiCadIpcClient::new(&mock.url) + .observe_selection(&target) + .expect("schematic selection is valid"); + assert_eq!(observation.selected_objects[0].kiid, "label-kiid"); + assert_eq!( + observation.selected_objects[0].object_type, + "schematic_label" + ); + assert_eq!(observation.sheet_instance_path, target.sheet_instance_path); +} + +#[test] +fn selection_refuses_wrong_project_document_and_sheet_without_retargeting() { + let cases = [ + ( + navigation_board_target(), + vec![kiapi::common::types::DocumentSpecifier { + project: Some(kiapi::common::types::ProjectSpecifier { + name: "other".to_string(), + path: r"C:\other".to_string(), + }), + ..navigation_board_document() + }], + IpcSelectionObservationErrorKind::WrongProject, + ), + ( + navigation_board_target(), + vec![kiapi::common::types::DocumentSpecifier { + identifier: Some( + kiapi::common::types::document_specifier::Identifier::BoardFilename( + "other.kicad_pcb".to_string(), + ), + ), + ..navigation_board_document() + }], + IpcSelectionObservationErrorKind::WrongDocument, + ), + ( + navigation_sheet_target(&["root", "requested"]), + vec![navigation_sheet_document(&["root", "other"])], + IpcSelectionObservationErrorKind::WrongSheetInstance, + ), + ]; + for (target, documents, expected) in cases { + let mock = spawn_mock(move |request| { + let message = request.message.expect("command"); + assert!(message.type_url.ends_with("GetOpenDocuments")); + Some(open_navigation_documents_response(documents.clone())) + }); + let error = KiCadIpcClient::new(&mock.url) + .observe_selection(&target) + .expect_err("target mismatch must fail closed"); + assert_eq!(selection_kind(&error), expected, "{error:#}"); + } +} + +#[test] +fn duplicate_document_identity_is_ambiguous_not_first_match() { + let mock = spawn_mock(move |request| { + let message = request.message.expect("command"); + assert!(message.type_url.ends_with("GetOpenDocuments")); + Some(open_navigation_documents_response(vec![ + navigation_board_document(), + navigation_board_document(), + ])) + }); + let error = KiCadIpcClient::new(&mock.url) + .observe_selection(&navigation_board_target()) + .expect_err("duplicates must be ambiguous"); + assert_eq!( + selection_kind(&error), + IpcSelectionObservationErrorKind::AmbiguousDocument + ); +} + +#[test] +fn malformed_or_unsupported_selected_objects_fail_the_whole_observation() { + let items = [ + builders::pack_any( + &kiapi::board::types::FootprintInstance::default(), + "kiapi.board.types.FootprintInstance", + ), + prost_types::Any { + type_url: "type.googleapis.com/kiapi.board.types.ReferenceImage".to_string(), + value: Vec::new(), + }, + ]; + let expected = [ + IpcSelectionObservationErrorKind::MalformedSelectedObject, + IpcSelectionObservationErrorKind::UnsupportedObjectType, + ]; + for (item, expected) in items.into_iter().zip(expected) { + let mock = spawn_mock(move |request| { + let message = request.message.expect("command"); + if message.type_url.ends_with("GetOpenDocuments") { + return Some(open_navigation_documents_response(vec![ + navigation_board_document(), + ])); + } + Some(selection_response(vec![item.clone()])) + }); + let error = KiCadIpcClient::new(&mock.url) + .observe_selection(&navigation_board_target()) + .expect_err("unverifiable selected object must fail closed"); + assert_eq!(selection_kind(&error), expected, "{error:#}"); + } +} + +#[test] +fn document_disappearing_during_selection_readback_is_stale_editor_state() { + let open_reads = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let reads_in_mock = open_reads.clone(); + let mock = spawn_mock(move |request| { + let message = request.message.expect("command"); + if message.type_url.ends_with("GetOpenDocuments") { + let read = reads_in_mock.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let documents = if read == 0 { + vec![navigation_board_document()] + } else { + Vec::new() + }; + return Some(open_navigation_documents_response(documents)); + } + Some(selection_response(Vec::new())) + }); + let error = KiCadIpcClient::new(&mock.url) + .observe_selection(&navigation_board_target()) + .expect_err("disappeared document is stale state"); + assert_eq!( + selection_kind(&error), + IpcSelectionObservationErrorKind::StaleEditorState + ); +} diff --git a/docs/KICAD_INTEGRATION.md b/docs/KICAD_INTEGRATION.md index aa36ef26..e9d45199 100644 --- a/docs/KICAD_INTEGRATION.md +++ b/docs/KICAD_INTEGRATION.md @@ -52,6 +52,16 @@ order. The same capability record reports stable typed activation and reveal as unsupported rather than routing callers through arbitrary `RunAction` strings. +`KiCadIpcClient::observe_selection` accepts one exact editor/document identity +from that observation, matches it before issuing `GetSelection`, and repeats +the open-document read afterward because KiCad's `SelectionResponse` carries +no response header. Every returned item is dispatched by its full protobuf +type URL and must contain a non-empty KIID; an unknown or malformed selected +item fails the entire observation rather than being dropped. The vendored +schematic protobuf currently decodes line and label selections. Schematic +symbols and other unmodelled types remain explicitly unsupported until KiCad +provides stable typed serialization for them. + ## Schematic-To-Board Sync `update_pcb_from_schematic` in `tools/pcb_sync.rs` is live-IPC-only. It uses diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index ca69ee90..013ef304 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -265,7 +265,7 @@ callable tools, the fix is to make the *first* listing complete: ``` in `konnect.toml` in the working directory, or a `settings.json` beside the binary. Every toolset is then loaded at -startup, so `tools/list` carries all 229 tools from the first call. +startup, so `tools/list` carries all 230 tools from the first call. It is off by default because it costs what the router exists to save: roughly 25K tokens per listing instead of ~2K. Turn it on only if your client needs it. diff --git a/packaging/metadata.json b/packaging/metadata.json index 046340cd..fc42066a 100644 --- a/packaging/metadata.json +++ b/packaging/metadata.json @@ -1,7 +1,7 @@ { "$schema": "https://go.kicad.org/pcm/schemas/v1", "name": "Konnect", - "description": "AI-assisted PCB design via the Model Context Protocol. Enables Claude and other AI assistants to design schematics and PCBs with 222 tools organized into on-demand toolsets.", + "description": "AI-assisted PCB design via the Model Context Protocol. Enables Claude and other AI assistants to design schematics and PCBs with 223 tools organized into on-demand toolsets.", "description_full": "Konnect exposes a complete set of KiCAD design tools to AI assistants via the Model Context Protocol (MCP). It supports schematic editing, PCB layout, local Freerouting MCP routing, library management, JLCPCB part search, ERC/DRC, design review audits, and full export pipelines. Tools are organized into 21 toolsets loaded on demand so the AI only sees relevant tools at once.", "identifier": "com.github.mixelpixx.konnect", "type": "plugin", diff --git a/plugin/plugin.json b/plugin/plugin.json index b3196e87..713053c0 100644 --- a/plugin/plugin.json +++ b/plugin/plugin.json @@ -1,7 +1,7 @@ { "identifier": "com.github.mixelpixx.konnect", "name": "Konnect", - "description": "AI-assisted PCB design via the Model Context Protocol. 222 tools for schematic editing, PCB layout, routing, design review, and manufacturing export.", + "description": "AI-assisted PCB design via the Model Context Protocol. 223 tools for schematic editing, PCB layout, routing, design review, and manufacturing export.", "runtime": { "type": "exec" }, diff --git a/tool-directory.md b/tool-directory.md index d5282d1f..609aec2c 100644 --- a/tool-directory.md +++ b/tool-directory.md @@ -13,7 +13,7 @@ Compatibility notes for removed or narrowed arguments are recorded in ## Overview - **21 toolsets** organized into 10 categories -- **222 registered tools** + **7 always-visible meta-tools** = **229 total** +- **223 registered tools** + **7 always-visible meta-tools** = **230 total** - **Discovery pattern**: the server pre-loads only the **starter kit** (`project`, `config`) so baseline `tools/list` costs ~2K tokens instead of ~23K. The LLM reads `list_toolboxes` → calls `load_toolset(name)` to expose additional tools on demand; `unload_toolset(name)` prunes them. `tools/list_changed` is notified on every mutation. If the LLM calls a tool whose toolset isn't loaded, the error names the owning toolset so recovery is a single `load_toolset` hop. `load_toolset` also accepts an array of names to load several toolsets with a single `tools/list` refresh. - **Observability**: every `tools/call` is recorded — ring buffer of the last 100 calls + per-tool counters + JSONL at `/logs/calls.jsonl`. The LLM self-diagnoses via `get_recent_calls` and `server_stats`. @@ -61,13 +61,14 @@ Seven tools, grouped into *discovery/routing*, *observability*, and *runtime dia | `snapshot_project` | Export the schematic and PCB to PDF as a timestamped snapshot/checkpoint. Useful before major edits. | | `open_schematic_viewer` | Launch the live schematic viewer (SVG with auto-refresh on file change). Use after placing components so the user can see changes in real time. | -### `editor_navigation` · 1 tool +### `editor_navigation` · 2 tools **Purpose:** Observe and semantically navigate exact KiCad editor, document, sheet, selection, and cross-probe context. **Source:** [`crates/konnect-core/src/tools/editor_navigation.rs`](crates/konnect-core/src/tools/editor_navigation.rs) | Tool | Description | |------|-------------| | `get_editor_state` | Observe the configured KiCad IPC endpoint's running version, addressable schematic/PCB editors, exact open document identities, capability availability, and explicit active-context limitations. | +| `get_editor_selection` | Read the selection from one exact editor, project, document, and hierarchical sheet instance with stable KIID/UUID identities and document-readback freshness checks. | --- From e5c6140bde7522d292a52db8ee07a530309d1974 Mon Sep 17 00:00:00 2001 From: dubesinhower Date: Sun, 30 Aug 2026 13:41:48 -0400 Subject: [PATCH 14/16] feat(navigation): resolve exact editor targets --- DEV.md | 6 +- README.md | 4 +- crates/konnect-core/src/router/registry.rs | 2 +- .../src/tools/editor_navigation.rs | 290 +++++++- crates/konnect-core/src/tools/mod.rs | 1 + .../src/tools/navigation_target.rs | 620 ++++++++++++++++++ crates/konnect-ipc/src/client.rs | 13 + docs/KICAD_INTEGRATION.md | 9 + docs/TROUBLESHOOTING.md | 2 +- packaging/metadata.json | 2 +- plugin/plugin.json | 2 +- tool-directory.md | 5 +- 12 files changed, 944 insertions(+), 12 deletions(-) create mode 100644 crates/konnect-core/src/tools/navigation_target.rs diff --git a/DEV.md b/DEV.md index f9f33cf7..6ca88a61 100644 --- a/DEV.md +++ b/DEV.md @@ -316,7 +316,7 @@ Source: [`crates/konnect-core/src/observability.rs`](crates/konnect-core/src/obs ## Tool Routing (Starter Kit + On-Demand Loading) -The server does NOT expose all 223 tools (230 total with the 7 meta-tools) in `tools/list` by default — that would cost ~23K tokens of context on every listing. Instead: +The server does NOT expose all 224 tools (231 total with the 7 meta-tools) in `tools/list` by default — that would cost ~23K tokens of context on every listing. Instead: - **Startup**: only `STARTER_KIT` toolsets are pre-loaded (see `router/registry.rs::STARTER_KIT`). Currently: `project`, `config`. Combined with the 7 meta-tools, baseline `tools/list` is 21 tools ≈ 2K tokens. - **On demand**: the LLM reads `list_toolboxes` → calls `load_toolset(name)` to expose a toolset's tools in subsequent `tools/list` responses. `unload_toolset(name)` prunes them when the task shifts. @@ -391,9 +391,9 @@ convention for other `kicad-cli`-calling code. ## Current Stats -- **21 toolsets, 223 tools** + 7 meta-tools (4 routing + 2 observability + 1 runtime diagnostic — see `tool-directory.md`) +- **21 toolsets, 224 tools** + 7 meta-tools (4 routing + 2 observability + 1 runtime diagnostic — see `tool-directory.md`) - Baseline `tools/list`: 21 tools / ~2K tokens (starter kit + meta-tools) -- Full-catalog `tools/list` (all loaded): 230 tools (223 registered + 7 meta) / ~25K tokens +- Full-catalog `tools/list` (all loaded): 231 tools (224 registered + 7 meta) / ~25K tokens - **0 IPC stubs** (all protobuf methods implemented) - **0 unimplemented tools** - **Specctra DSN/SES are PCB-editor operations**, not `kicad-cli` commands. diff --git a/README.md b/README.md index a4f89f32..d82a7ce1 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Rust binary — that lets Claude and other AI assistants design schematics and PCBs through the [Model Context Protocol](https://modelcontextprotocol.io) (MCP). -**223 tools across 21 on-demand toolsets.** Schematic capture, PCB layout and +**224 tools across 21 on-demand toolsets.** Schematic capture, PCB layout and routing, ERC/DRC, design-review audits, JLCPCB part search, reference circuits, and a full manufacturing export pipeline — with bundled skills and agents that teach Claude KiCAD conventions out of the box. @@ -70,7 +70,7 @@ through its own S-expression engine with atomic writes (write, fsync, rename), U preservation, and round-trip tests — no third-party schematic library with known gaps, no text-manipulation workarounds. -**Context economy is a feature.** Exposing all 223 tools to an LLM costs roughly 23K +**Context economy is a feature.** Exposing all 224 tools to an LLM costs roughly 23K tokens of context on every listing. Konnect's router loads a starter kit (~2K tokens) and lets the model pull in toolsets on demand — plus built-in observability (`get_recent_calls`, `server_stats`, JSONL call logs) so the model can diagnose its diff --git a/crates/konnect-core/src/router/registry.rs b/crates/konnect-core/src/router/registry.rs index e92b6fcb..c20f9166 100644 --- a/crates/konnect-core/src/router/registry.rs +++ b/crates/konnect-core/src/router/registry.rs @@ -28,7 +28,7 @@ pub static ALL_TOOLSETS: &[ToolsetMeta] = &[ name: "editor_navigation", description: "Observe and semantically navigate exact KiCad editor, document, sheet, selection, and cross-probe context", category: "project", - tool_count: 2, + tool_count: 3, }, ToolsetMeta { name: "sch_components", diff --git a/crates/konnect-core/src/tools/editor_navigation.rs b/crates/konnect-core/src/tools/editor_navigation.rs index 5b3f6e70..21a9f15d 100644 --- a/crates/konnect-core/src/tools/editor_navigation.rs +++ b/crates/konnect-core/src/tools/editor_navigation.rs @@ -13,6 +13,11 @@ use konnect_ipc::{ IpcSheetInstancePath, }; use serde_json::json; +use std::path::PathBuf; + +use super::navigation_target::{ + resolve_navigation_target, NavigationTargetErrorKind, NavigationTargetRequest, +}; pub fn tools() -> Vec { vec![ @@ -47,6 +52,25 @@ pub fn tools() -> Vec { }), |args, ctx| async move { handle_get_editor_selection(args, ctx).await } ), + tool!( + "resolve_navigation_target", + "Deterministically resolve one exact saved KiCad object in an explicitly open project, document, editor, and schematic hierarchy instance. Stable KIID/UUID identity is primary; a human reference is accepted only when it resolves uniquely and ambiguity is returned with candidates.", + json!({ + "type": "object", + "properties": { + "editor": { "type": "string", "enum": ["schematic", "pcb"] }, + "project_name": { "type": "string" }, + "project_path": { "type": "string" }, + "document_path": { "type": "string", "description": "Exact saved .kicad_sch or .kicad_pcb document" }, + "sheet_instance_path": { "type": "array", "items": { "type": "string" } }, + "sheet_path_human_readable": { "type": "string" }, + "object_kiid": { "type": "string", "description": "Preferred stable KIID/UUID" }, + "human_reference": { "type": "string", "description": "Fallback reference such as C10; must resolve uniquely" } + }, + "required": ["editor", "project_name", "project_path", "document_path"] + }), + |args, ctx| async move { handle_resolve_navigation_target(args, ctx).await } + ), ] } @@ -283,6 +307,151 @@ fn selection_error_result(error: anyhow::Error) -> CallToolResult { } } +async fn handle_resolve_navigation_target( + args: &serde_json::Value, + ctx: &ToolContext, +) -> anyhow::Result { + let request = match parse_navigation_target_request(args) { + Ok(request) => request, + Err(result) => return Ok(result), + }; + let live_document = IpcEditorDocument { + editor: request.editor, + project: Some(request.project.clone()), + document_path: (request.editor == IpcEditorKind::Pcb) + .then(|| request.document_path.display().to_string()), + sheet_instance_path: request.sheet_instance_path.clone(), + }; + let address = ctx.config.ipc_address.clone(); + if address.is_empty() { + return Ok(editor_unavailable("no KiCad IPC endpoint is configured")); + } + let observed = tokio::task::spawn_blocking(move || { + konnect_ipc::KiCadIpcClient::new(address).observe_exact_open_document(&live_document) + }) + .await?; + let observed = match observed { + Ok(observed) => observed, + Err(error) => return Ok(selection_error_result(error)), + }; + + let resolved = tokio::task::spawn_blocking(move || resolve_navigation_target(&request)).await?; + match resolved { + Ok(target) => Ok(CallToolResult::json(&json!({ + "target": target, + "live_context_evidence": { + "source": "kicad_ipc_get_open_documents", + "document": observed + } + }))), + Err(error) => Ok(navigation_target_error_result(error)), + } +} + +fn parse_navigation_target_request( + args: &serde_json::Value, +) -> Result { + let editor = match require_str(args, "editor")? { + "schematic" => IpcEditorKind::Schematic, + "pcb" => IpcEditorKind::Pcb, + _ => return Err(invalid_arg("editor", "expected 'schematic' or 'pcb'")), + }; + let project_name = require_str(args, "project_name")?; + let project_path = require_str(args, "project_path")?; + let document_path = require_str(args, "document_path")?; + if project_name.is_empty() || project_path.is_empty() || document_path.is_empty() { + return Err(invalid_arg( + "project_name", + "project and document identity strings must not be empty", + )); + } + + let sheet_instance_path = match editor { + IpcEditorKind::Pcb => { + if !args["sheet_instance_path"].is_null() + || !args["sheet_path_human_readable"].is_null() + { + return Err(invalid_arg( + "sheet_instance_path", + "PCB targets cannot carry schematic sheet identity", + )); + } + None + } + IpcEditorKind::Schematic => { + let ids = require_array(args, "sheet_instance_path")? + .iter() + .map(|value| value.as_str().map(str::to_string)) + .collect::>>() + .ok_or_else(|| { + invalid_arg("sheet_instance_path", "every entry must be a string") + })?; + if ids.is_empty() || ids.iter().any(String::is_empty) { + return Err(invalid_arg( + "sheet_instance_path", + "must contain non-empty root-to-leaf KIIIDs", + )); + } + Some(IpcSheetInstancePath { + kiids: ids, + human_readable: opt_str(args, "sheet_path_human_readable") + .unwrap_or("") + .to_string(), + }) + } + }; + let object_kiid = opt_str(args, "object_kiid").map(str::to_string); + let human_reference = opt_str(args, "human_reference").map(str::to_string); + if object_kiid.as_deref().is_some_and(str::is_empty) + || human_reference.as_deref().is_some_and(str::is_empty) + || object_kiid.is_some() == human_reference.is_some() + { + return Err(invalid_arg( + "object_kiid", + "provide exactly one non-empty object_kiid or human_reference", + )); + } + Ok(NavigationTargetRequest { + editor, + project: IpcProjectIdentity { + name: project_name.to_string(), + path: project_path.to_string(), + }, + document_path: PathBuf::from(document_path), + sheet_instance_path, + object_kiid, + human_reference, + }) +} + +fn navigation_target_error_result( + error: super::navigation_target::NavigationTargetError, +) -> CallToolResult { + let kind = match error.kind { + NavigationTargetErrorKind::WrongProject => ToolErrorKind::WrongProject { + requested: error.target.clone(), + open_projects: error.candidates.clone(), + }, + NavigationTargetErrorKind::WrongDocument => ToolErrorKind::WrongDocument { + requested: error.target.clone(), + open_documents: error.candidates.clone(), + }, + NavigationTargetErrorKind::WrongSheetInstance => ToolErrorKind::WrongSheetInstance { + requested: error.target.clone(), + open_sheet_instances: error.candidates.clone(), + }, + NavigationTargetErrorKind::AmbiguousTarget => ToolErrorKind::AmbiguousTarget { + target: error.target.clone(), + candidates: error.candidates.clone(), + }, + NavigationTargetErrorKind::StaleTarget => ToolErrorKind::StaleTarget { + target: error.target.clone(), + reason: error.reason.clone(), + }, + }; + CallToolResult::error_kind(kind, error.to_string()) +} + #[cfg(test)] mod tests { use super::*; @@ -428,10 +597,57 @@ mod tests { url } + fn spawn_open_board_mock(project_path: String) -> String { + static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let url = format!( + "inproc://navigation-resolver-core-{}", + NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + ); + let socket = nng::Socket::new(nng::Protocol::Rep0).expect("mock socket"); + socket.listen(&url).expect("listen"); + std::thread::spawn(move || { + let message = socket.recv().expect("request"); + let request = + kiapi::common::ApiRequest::decode(message.as_slice()).expect("decode request"); + assert!(request + .message + .as_ref() + .is_some_and(|message| message.type_url.ends_with("GetOpenDocuments"))); + let response = kiapi::common::ApiResponse { + status: Some(kiapi::common::ApiResponseStatus { + status: kiapi::common::ApiStatusCode::AsOk as i32, + error_message: String::new(), + }), + header: None, + message: Some(builders::pack_any( + &kiapi::common::commands::GetOpenDocumentsResponse { + documents: vec![kiapi::common::types::DocumentSpecifier { + r#type: kiapi::common::types::DocumentType::DoctypePcb as i32, + identifier: Some( + kiapi::common::types::document_specifier::Identifier::BoardFilename( + "layout.kicad_pcb".to_string(), + ), + ), + project: Some(kiapi::common::types::ProjectSpecifier { + name: "nav".to_string(), + path: project_path, + }), + }], + }, + "kiapi.common.commands.GetOpenDocumentsResponse", + )), + }; + socket + .send(nng::Message::from(response.encode_to_vec().as_slice())) + .expect("response"); + }); + url + } + #[test] fn public_tool_is_read_only_and_takes_no_required_arguments() { let definitions = tools(); - assert_eq!(definitions.len(), 2); + assert_eq!(definitions.len(), 3); let state = definitions .iter() .find(|tool| tool.name == "get_editor_state") @@ -445,6 +661,14 @@ mod tests { selection.input_schema["required"], json!(["editor", "project_name", "project_path"]) ); + let resolver = definitions + .iter() + .find(|tool| tool.name == "resolve_navigation_target") + .expect("resolver tool"); + assert_eq!( + resolver.input_schema["required"], + json!(["editor", "project_name", "project_path", "document_path"]) + ); } #[tokio::test] @@ -548,4 +772,68 @@ mod tests { Some("wrong_sheet_instance") ); } + + #[test] + fn resolver_parser_requires_exactly_one_identifier() { + let base = json!({ + "editor": "pcb", + "project_name": "navigation", + "project_path": r"C:\design", + "document_path": r"C:\design\navigation.kicad_pcb" + }); + let missing = parse_navigation_target_request(&base).expect_err("identifier required"); + assert_eq!( + extract_error_kind(&missing).as_deref(), + Some("invalid_argument") + ); + + let mut both = base; + both["object_kiid"] = json!("id"); + both["human_reference"] = json!("C10"); + let ambiguous = parse_navigation_target_request(&both).expect_err("one identifier only"); + assert_eq!( + extract_error_kind(&ambiguous).as_deref(), + Some("invalid_argument") + ); + } + + #[tokio::test] + async fn public_resolver_keeps_live_and_structural_evidence_separate() { + let temp = tempfile::tempdir().unwrap(); + std::fs::write(temp.path().join("nav.kicad_pro"), "{}").unwrap(); + let board = temp.path().join("layout.kicad_pcb"); + std::fs::write( + &board, + "(kicad_pcb (footprint \"Capacitor:C\" (layer \"F.Cu\") (at 1 2) \ + (uuid \"fp-c10\") (property \"Reference\" \"C10\")))", + ) + .unwrap(); + let project_path = temp.path().display().to_string(); + let result = handle_resolve_navigation_target( + &json!({ + "editor": "pcb", + "project_name": "nav", + "project_path": project_path.clone(), + "document_path": board.display().to_string(), + "human_reference": "C10" + }), + &context(spawn_open_board_mock(project_path)), + ) + .await + .expect("handler result"); + assert!(!result.is_error); + let ToolContent::Text { text } = &result.content[0] else { + panic!("expected text result"); + }; + let body: serde_json::Value = serde_json::from_str(text).unwrap(); + assert_eq!(body["target"]["object"]["kiid"], "fp-c10"); + assert_eq!( + body["target"]["structural_evidence"]["source"], + "saved_kicad_structure" + ); + assert_eq!( + body["live_context_evidence"]["source"], + "kicad_ipc_get_open_documents" + ); + } } diff --git a/crates/konnect-core/src/tools/mod.rs b/crates/konnect-core/src/tools/mod.rs index 3d742ae5..2c447cb4 100644 --- a/crates/konnect-core/src/tools/mod.rs +++ b/crates/konnect-core/src/tools/mod.rs @@ -11,6 +11,7 @@ mod footprint_models; pub mod integration; pub mod library; pub mod manufacturing; +pub(crate) mod navigation_target; pub mod pcb_board; pub mod pcb_components; pub mod pcb_export; diff --git a/crates/konnect-core/src/tools/navigation_target.rs b/crates/konnect-core/src/tools/navigation_target.rs new file mode 100644 index 00000000..4194b837 --- /dev/null +++ b/crates/konnect-core/src/tools/navigation_target.rs @@ -0,0 +1,620 @@ +//! Pure deterministic navigation-target resolution. +//! +//! This module reads saved KiCad structure only. Live editor/document proof is +//! performed separately by `editor_navigation` so results never blur file +//! evidence with IPC evidence. + +use konnect_ipc::{IpcEditorKind, IpcProjectIdentity, IpcSheetInstancePath}; +use konnect_sexp::SexpNode; +use serde::Serialize; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct NavigationTargetRequest { + pub editor: IpcEditorKind, + pub project: IpcProjectIdentity, + pub document_path: PathBuf, + pub sheet_instance_path: Option, + pub object_kiid: Option, + pub human_reference: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub(crate) struct ResolvedNavigationObject { + pub kiid: String, + pub object_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub human_reference: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub(crate) struct NavigationStructuralEvidence { + pub source: String, + pub project_file: String, + pub document_path: String, + pub resolver: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub(crate) struct ResolvedNavigationTarget { + pub project: IpcProjectIdentity, + pub editor: IpcEditorKind, + pub document_path: String, + pub sheet_instance_path: Option, + pub object: ResolvedNavigationObject, + pub structural_evidence: NavigationStructuralEvidence, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum NavigationTargetErrorKind { + WrongProject, + WrongDocument, + WrongSheetInstance, + AmbiguousTarget, + StaleTarget, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct NavigationTargetError { + pub kind: NavigationTargetErrorKind, + pub target: String, + pub candidates: Vec, + pub reason: String, +} + +impl std::fmt::Display for NavigationTargetError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + formatter, + "cannot resolve navigation target '{}': {}", + self.target, self.reason + ) + } +} + +impl std::error::Error for NavigationTargetError {} + +pub(crate) fn resolve_navigation_target( + request: &NavigationTargetRequest, +) -> Result { + let project_file = PathBuf::from(&request.project.path) + .join(&request.project.name) + .with_extension("kicad_pro"); + if !project_file.is_file() { + return Err(target_error( + NavigationTargetErrorKind::WrongProject, + &request.project.name, + Vec::new(), + format!( + "the explicit project file '{}' does not exist", + project_file.display() + ), + )); + } + if !request.document_path.is_file() { + return Err(target_error( + NavigationTargetErrorKind::StaleTarget, + &request.document_path.display().to_string(), + Vec::new(), + "the saved document no longer exists".to_string(), + )); + } + + let (tree, instance_path) = match request.editor { + IpcEditorKind::Schematic => { + if !has_extension(&request.document_path, "kicad_sch") { + return Err(wrong_document(request, "expected a .kicad_sch document")); + } + let requested_instance = request.sheet_instance_path.as_ref().ok_or_else(|| { + target_error( + NavigationTargetErrorKind::WrongSheetInstance, + &request.document_path.display().to_string(), + Vec::new(), + "schematic resolution requires an explicit instance path".to_string(), + ) + })?; + validate_schematic_context(request, &project_file, requested_instance)?; + let (_, tree) = konnect_sexp::schematic::read_schematic(&request.document_path) + .map_err(|error| { + target_error( + NavigationTargetErrorKind::StaleTarget, + &request.document_path.display().to_string(), + Vec::new(), + format!("saved schematic cannot be parsed: {error}"), + ) + })?; + (tree, Some(requested_instance.clone())) + } + IpcEditorKind::Pcb => { + if !has_extension(&request.document_path, "kicad_pcb") { + return Err(wrong_document(request, "expected a .kicad_pcb document")); + } + if request.sheet_instance_path.is_some() { + return Err(target_error( + NavigationTargetErrorKind::WrongSheetInstance, + &request.document_path.display().to_string(), + Vec::new(), + "PCB targets cannot carry a schematic instance path".to_string(), + )); + } + let source = std::fs::read_to_string(&request.document_path).map_err(|error| { + target_error( + NavigationTargetErrorKind::StaleTarget, + &request.document_path.display().to_string(), + Vec::new(), + format!("saved PCB cannot be read: {error}"), + ) + })?; + let tree = konnect_sexp::parse_sexp(&source).map_err(|error| { + target_error( + NavigationTargetErrorKind::StaleTarget, + &request.document_path.display().to_string(), + Vec::new(), + format!("saved PCB cannot be parsed: {error}"), + ) + })?; + (tree, None) + } + }; + + let object = resolve_object(request, &tree)?; + Ok(ResolvedNavigationTarget { + project: request.project.clone(), + editor: request.editor, + document_path: request.document_path.display().to_string(), + sheet_instance_path: instance_path, + object, + structural_evidence: NavigationStructuralEvidence { + source: "saved_kicad_structure".to_string(), + project_file: project_file.display().to_string(), + document_path: request.document_path.display().to_string(), + resolver: if request.object_kiid.is_some() { + "exact_kiid".to_string() + } else { + "unique_human_reference".to_string() + }, + }, + }) +} + +fn validate_schematic_context( + request: &NavigationTargetRequest, + project_file: &Path, + requested_instance: &IpcSheetInstancePath, +) -> Result<(), NavigationTargetError> { + let ownership = + super::resolve_schematic_ownership(&request.document_path).map_err(|error| { + let (kind, candidates) = match &error { + super::SchematicTargetError::AmbiguousProject { roots, .. } => ( + NavigationTargetErrorKind::AmbiguousTarget, + roots + .iter() + .map(|root| root.display().to_string()) + .collect(), + ), + super::SchematicTargetError::StaleTarget { .. } => { + (NavigationTargetErrorKind::StaleTarget, Vec::new()) + } + }; + target_error( + kind, + &request.document_path.display().to_string(), + candidates, + error.to_string(), + ) + })?; + if let Some(owner) = &ownership { + if canonical(&owner.project_file) != canonical(project_file) { + return Err(target_error( + NavigationTargetErrorKind::WrongProject, + &request.project.name, + vec![owner.project_file.display().to_string()], + "the saved hierarchy belongs to another project".to_string(), + )); + } + } else if canonical(&project_file.with_extension("kicad_sch")) + != canonical(&request.document_path) + { + return Err(wrong_document( + request, + "the schematic is not reachable from the explicit project root", + )); + } + + let mut schematic = + konnect_schematic_editor::Schematic::load(&request.document_path).map_err(|error| { + target_error( + NavigationTargetErrorKind::StaleTarget, + &request.document_path.display().to_string(), + Vec::new(), + format!("saved schematic cannot be loaded: {error}"), + ) + })?; + let context = + super::sheet_instance_context(&request.document_path, &mut schematic).map_err(|error| { + target_error( + NavigationTargetErrorKind::StaleTarget, + &request.document_path.display().to_string(), + Vec::new(), + error.to_string(), + ) + })?; + if context.project_name != request.project.name { + return Err(target_error( + NavigationTargetErrorKind::WrongProject, + &request.project.name, + vec![context.project_name], + "schematic instance metadata names another project".to_string(), + )); + } + super::validate_sheet_instance_state(&request.document_path, &schematic, &context).map_err( + |error| { + target_error( + NavigationTargetErrorKind::StaleTarget, + &request.document_path.display().to_string(), + Vec::new(), + error.to_string(), + ) + }, + )?; + + let requested = format!("/{}", requested_instance.kiids.join("/")); + if !context.instance_paths.contains(&requested) { + return Err(target_error( + NavigationTargetErrorKind::WrongSheetInstance, + &requested, + context.instance_paths, + "the requested hierarchy instance does not own this schematic document".to_string(), + )); + } + Ok(()) +} + +fn resolve_object( + request: &NavigationTargetRequest, + tree: &SexpNode, +) -> Result { + match (&request.object_kiid, &request.human_reference) { + (Some(kiid), None) => resolve_by_kiid(request, tree, kiid), + (None, Some(reference)) => resolve_by_reference(request, tree, reference), + _ => Err(target_error( + NavigationTargetErrorKind::StaleTarget, + &request.document_path.display().to_string(), + Vec::new(), + "provide exactly one of object_kiid or human_reference".to_string(), + )), + } +} + +fn resolve_by_kiid( + request: &NavigationTargetRequest, + tree: &SexpNode, + kiid: &str, +) -> Result { + let mut candidates = Vec::new(); + collect_uuid_objects(tree, request.editor, &mut candidates); + let matches = candidates + .into_iter() + .filter(|object| object.kiid == kiid) + .collect::>(); + match matches.as_slice() { + [target] => Ok(target.clone()), + [] => Err(target_error( + NavigationTargetErrorKind::StaleTarget, + kiid, + Vec::new(), + "no object with this KIID exists in the exact saved document".to_string(), + )), + many => Err(target_error( + NavigationTargetErrorKind::AmbiguousTarget, + kiid, + many.iter() + .map(|object| format!("{}:{}", object.object_type, object.kiid)) + .collect(), + "the saved document contains a duplicate KIID".to_string(), + )), + } +} + +fn resolve_by_reference( + request: &NavigationTargetRequest, + tree: &SexpNode, + reference: &str, +) -> Result { + let mut candidates = match request.editor { + IpcEditorKind::Schematic => konnect_sexp::schematic::extract_symbol_instances(tree) + .into_iter() + .filter(|symbol| symbol.reference == reference) + .map(|symbol| ResolvedNavigationObject { + kiid: symbol.uuid.unwrap_or_default(), + object_type: "schematic_symbol".to_string(), + human_reference: Some(symbol.reference), + }) + .collect::>(), + IpcEditorKind::Pcb => konnect_sexp::board::footprints(tree) + .into_iter() + .filter_map(|footprint| { + let observed = footprint_reference(footprint)?; + (observed == reference).then(|| ResolvedNavigationObject { + kiid: footprint.find_str("uuid").unwrap_or_default().to_string(), + object_type: "pcb_footprint".to_string(), + human_reference: Some(observed), + }) + }) + .collect::>(), + }; + candidates.sort_by(|left, right| left.kiid.cmp(&right.kiid)); + if candidates.iter().any(|candidate| candidate.kiid.is_empty()) { + return Err(target_error( + NavigationTargetErrorKind::StaleTarget, + reference, + Vec::new(), + "a matching saved object has no stable KIID".to_string(), + )); + } + match candidates.as_slice() { + [target] => Ok(target.clone()), + [] => Err(target_error( + NavigationTargetErrorKind::StaleTarget, + reference, + Vec::new(), + "the human reference does not exist in the exact saved document".to_string(), + )), + many => Err(target_error( + NavigationTargetErrorKind::AmbiguousTarget, + reference, + many.iter() + .map(|object| format!("{}:{}", object.object_type, object.kiid)) + .collect(), + "the human reference resolves to more than one object".to_string(), + )), + } +} + +fn collect_uuid_objects( + node: &SexpNode, + editor: IpcEditorKind, + output: &mut Vec, +) { + let Some(children) = node.children() else { + return; + }; + let head = node.head().unwrap_or(""); + if head == "lib_symbols" { + return; + } + if !matches!(head, "kicad_sch" | "kicad_pcb") { + if let Some(kiid) = node.find_str("uuid").filter(|id| !id.is_empty()) { + output.push(ResolvedNavigationObject { + kiid: kiid.to_string(), + object_type: object_type(editor, head), + human_reference: match editor { + IpcEditorKind::Schematic if head == "symbol" => node + .find_all("property") + .into_iter() + .find(|property| { + property.get(1).and_then(SexpNode::as_str) == Some("Reference") + }) + .and_then(|property| property.get(2)) + .and_then(SexpNode::as_str) + .map(str::to_string), + IpcEditorKind::Pcb if head == "footprint" => footprint_reference(node), + _ => None, + }, + }); + } + } + for child in children.iter().skip(1) { + if child.head() != Some("uuid") { + collect_uuid_objects(child, editor, output); + } + } +} + +fn object_type(editor: IpcEditorKind, head: &str) -> String { + let prefix = editor.as_str(); + let kind = match (editor, head) { + (IpcEditorKind::Schematic, "symbol") => "symbol", + (IpcEditorKind::Pcb, "footprint") => "footprint", + (_, "property") => "field", + (_, other) if !other.is_empty() => other, + _ => "object", + }; + format!("{prefix}_{kind}") +} + +fn footprint_reference(footprint: &SexpNode) -> Option { + footprint + .find_all("property") + .into_iter() + .find(|property| property.get(1).and_then(SexpNode::as_str) == Some("Reference")) + .and_then(|property| property.get(2)) + .and_then(SexpNode::as_str) + .map(str::to_string) + .or_else(|| { + footprint + .find_all("fp_text") + .into_iter() + .find(|text| text.get(1).and_then(SexpNode::as_str) == Some("reference")) + .and_then(|text| text.get(2)) + .and_then(SexpNode::as_str) + .map(str::to_string) + }) +} + +fn wrong_document(request: &NavigationTargetRequest, reason: &str) -> NavigationTargetError { + target_error( + NavigationTargetErrorKind::WrongDocument, + &request.document_path.display().to_string(), + Vec::new(), + reason.to_string(), + ) +} + +fn target_error( + kind: NavigationTargetErrorKind, + target: &str, + candidates: Vec, + reason: String, +) -> NavigationTargetError { + NavigationTargetError { + kind, + target: target.to_string(), + candidates, + reason, + } +} + +fn has_extension(path: &Path, extension: &str) -> bool { + path.extension() + .and_then(|value| value.to_str()) + .is_some_and(|value| value.eq_ignore_ascii_case(extension)) +} + +fn canonical(path: &Path) -> PathBuf { + path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn write(path: &Path, source: &str) { + fs::write(path, source).expect("write fixture"); + } + + fn project(root: &Path) -> IpcProjectIdentity { + IpcProjectIdentity { + name: "nav".to_string(), + path: root.display().to_string(), + } + } + + fn schematic_request(root: &Path) -> NavigationTargetRequest { + NavigationTargetRequest { + editor: IpcEditorKind::Schematic, + project: project(root), + document_path: root.join("nav.kicad_sch"), + sheet_instance_path: Some(IpcSheetInstancePath { + kiids: vec!["root".to_string()], + human_readable: "/".to_string(), + }), + object_kiid: Some("sym-c10".to_string()), + human_reference: None, + } + } + + fn fixture(root: &Path, symbols: &str) { + write(&root.join("nav.kicad_pro"), "{}"); + write( + &root.join("nav.kicad_sch"), + &format!( + "(kicad_sch (uuid \"root\") {symbols} (sheet_instances (path \"/\" (page \"1\"))))" + ), + ); + } + + fn symbol(reference: &str, uuid: &str) -> String { + format!( + "(symbol (lib_id \"Device:C\") (at 10 10 0) (uuid \"{uuid}\") \ + (property \"Reference\" \"{reference}\") \ + (instances (project \"nav\" (path \"/root\" (reference \"{reference}\") (unit 1)))))" + ) + } + + #[test] + fn exact_schematic_kiid_resolves_with_separate_structural_evidence() { + let temp = tempfile::tempdir().unwrap(); + fixture(temp.path(), &symbol("C10", "sym-c10")); + let target = resolve_navigation_target(&schematic_request(temp.path())).unwrap(); + assert_eq!(target.object.kiid, "sym-c10"); + assert_eq!(target.object.object_type, "schematic_symbol"); + assert_eq!(target.structural_evidence.source, "saved_kicad_structure"); + assert_eq!(target.structural_evidence.resolver, "exact_kiid"); + } + + #[test] + fn unique_human_reference_resolves_but_duplicates_return_candidates() { + let temp = tempfile::tempdir().unwrap(); + fixture(temp.path(), &symbol("C10", "sym-c10")); + let mut request = schematic_request(temp.path()); + request.object_kiid = None; + request.human_reference = Some("C10".to_string()); + assert_eq!( + resolve_navigation_target(&request).unwrap().object.kiid, + "sym-c10" + ); + + fixture( + temp.path(), + &format!("{} {}", symbol("C10", "sym-a"), symbol("C10", "sym-b")), + ); + let error = resolve_navigation_target(&request).unwrap_err(); + assert_eq!(error.kind, NavigationTargetErrorKind::AmbiguousTarget); + assert_eq!(error.candidates.len(), 2); + } + + #[test] + fn missing_stale_wrong_project_and_wrong_sheet_fail_closed() { + let temp = tempfile::tempdir().unwrap(); + fixture(temp.path(), &symbol("C10", "sym-c10")); + + let mut missing = schematic_request(temp.path()); + missing.object_kiid = Some("gone".to_string()); + assert_eq!( + resolve_navigation_target(&missing).unwrap_err().kind, + NavigationTargetErrorKind::StaleTarget + ); + + let mut wrong_project = schematic_request(temp.path()); + wrong_project.project.name = "other".to_string(); + assert_eq!( + resolve_navigation_target(&wrong_project).unwrap_err().kind, + NavigationTargetErrorKind::WrongProject + ); + + let mut wrong_sheet = schematic_request(temp.path()); + wrong_sheet.sheet_instance_path = Some(IpcSheetInstancePath { + kiids: vec!["other-root".to_string()], + human_readable: "/other".to_string(), + }); + assert_eq!( + resolve_navigation_target(&wrong_sheet).unwrap_err().kind, + NavigationTargetErrorKind::WrongSheetInstance + ); + } + + #[test] + fn exact_board_footprint_and_unique_reference_resolve() { + let temp = tempfile::tempdir().unwrap(); + write(&temp.path().join("nav.kicad_pro"), "{}"); + let board = temp.path().join("layout.kicad_pcb"); + write( + &board, + "(kicad_pcb (footprint \"Capacitor:C\" (layer \"F.Cu\") (at 1 2) \ + (uuid \"fp-c10\") (property \"Reference\" \"C10\")))", + ); + let mut request = NavigationTargetRequest { + editor: IpcEditorKind::Pcb, + project: project(temp.path()), + document_path: board, + sheet_instance_path: None, + object_kiid: Some("fp-c10".to_string()), + human_reference: None, + }; + assert_eq!( + resolve_navigation_target(&request) + .unwrap() + .object + .object_type, + "pcb_footprint" + ); + request.object_kiid = None; + request.human_reference = Some("C10".to_string()); + assert_eq!( + resolve_navigation_target(&request).unwrap().object.kiid, + "fp-c10" + ); + } +} diff --git a/crates/konnect-ipc/src/client.rs b/crates/konnect-ipc/src/client.rs index 1950820a..fa880b18 100644 --- a/crates/konnect-ipc/src/client.rs +++ b/crates/konnect-ipc/src/client.rs @@ -777,6 +777,19 @@ impl KiCadIpcClient { }) } + /// Prove that one exact editor/document/sheet identity is currently open. + /// + /// This is the read-only context gate used by semantic target resolution; + /// it shares the same no-fallback matching rules as selection observation + /// without issuing a selection query. + pub fn observe_exact_open_document( + &self, + requested: &IpcEditorDocument, + ) -> Result { + let document = self.resolve_selection_document(requested)?; + editor_document_from_specifier(requested.editor, document) + } + fn resolve_selection_document( &self, requested: &IpcEditorDocument, diff --git a/docs/KICAD_INTEGRATION.md b/docs/KICAD_INTEGRATION.md index e9d45199..1e55564c 100644 --- a/docs/KICAD_INTEGRATION.md +++ b/docs/KICAD_INTEGRATION.md @@ -62,6 +62,15 @@ schematic protobuf currently decodes line and label selections. Schematic symbols and other unmodelled types remain explicitly unsupported until KiCad provides stable typed serialization for them. +Navigation target resolution keeps two evidence channels in the same result +without merging them. `GetOpenDocuments` proves that the exact requested live +project/document/sheet context is still addressable; the saved `.kicad_sch` or +`.kicad_pcb` structure proves object KIID and human-reference identity. Stable +KIID lookup is primary. A reference such as `C10` is accepted only when one +object matches in the exact document and sheet instance; duplicates return +structured candidates, and stale project ownership or symbol-instance paths +fail closed before any editor mutation. + ## Schematic-To-Board Sync `update_pcb_from_schematic` in `tools/pcb_sync.rs` is live-IPC-only. It uses diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 013ef304..e39de72e 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -265,7 +265,7 @@ callable tools, the fix is to make the *first* listing complete: ``` in `konnect.toml` in the working directory, or a `settings.json` beside the binary. Every toolset is then loaded at -startup, so `tools/list` carries all 230 tools from the first call. +startup, so `tools/list` carries all 231 tools from the first call. It is off by default because it costs what the router exists to save: roughly 25K tokens per listing instead of ~2K. Turn it on only if your client needs it. diff --git a/packaging/metadata.json b/packaging/metadata.json index fc42066a..6e43cca7 100644 --- a/packaging/metadata.json +++ b/packaging/metadata.json @@ -1,7 +1,7 @@ { "$schema": "https://go.kicad.org/pcm/schemas/v1", "name": "Konnect", - "description": "AI-assisted PCB design via the Model Context Protocol. Enables Claude and other AI assistants to design schematics and PCBs with 223 tools organized into on-demand toolsets.", + "description": "AI-assisted PCB design via the Model Context Protocol. Enables Claude and other AI assistants to design schematics and PCBs with 224 tools organized into on-demand toolsets.", "description_full": "Konnect exposes a complete set of KiCAD design tools to AI assistants via the Model Context Protocol (MCP). It supports schematic editing, PCB layout, local Freerouting MCP routing, library management, JLCPCB part search, ERC/DRC, design review audits, and full export pipelines. Tools are organized into 21 toolsets loaded on demand so the AI only sees relevant tools at once.", "identifier": "com.github.mixelpixx.konnect", "type": "plugin", diff --git a/plugin/plugin.json b/plugin/plugin.json index 713053c0..db7c9142 100644 --- a/plugin/plugin.json +++ b/plugin/plugin.json @@ -1,7 +1,7 @@ { "identifier": "com.github.mixelpixx.konnect", "name": "Konnect", - "description": "AI-assisted PCB design via the Model Context Protocol. 223 tools for schematic editing, PCB layout, routing, design review, and manufacturing export.", + "description": "AI-assisted PCB design via the Model Context Protocol. 224 tools for schematic editing, PCB layout, routing, design review, and manufacturing export.", "runtime": { "type": "exec" }, diff --git a/tool-directory.md b/tool-directory.md index 609aec2c..a585ed4c 100644 --- a/tool-directory.md +++ b/tool-directory.md @@ -13,7 +13,7 @@ Compatibility notes for removed or narrowed arguments are recorded in ## Overview - **21 toolsets** organized into 10 categories -- **223 registered tools** + **7 always-visible meta-tools** = **230 total** +- **224 registered tools** + **7 always-visible meta-tools** = **231 total** - **Discovery pattern**: the server pre-loads only the **starter kit** (`project`, `config`) so baseline `tools/list` costs ~2K tokens instead of ~23K. The LLM reads `list_toolboxes` → calls `load_toolset(name)` to expose additional tools on demand; `unload_toolset(name)` prunes them. `tools/list_changed` is notified on every mutation. If the LLM calls a tool whose toolset isn't loaded, the error names the owning toolset so recovery is a single `load_toolset` hop. `load_toolset` also accepts an array of names to load several toolsets with a single `tools/list` refresh. - **Observability**: every `tools/call` is recorded — ring buffer of the last 100 calls + per-tool counters + JSONL at `/logs/calls.jsonl`. The LLM self-diagnoses via `get_recent_calls` and `server_stats`. @@ -61,7 +61,7 @@ Seven tools, grouped into *discovery/routing*, *observability*, and *runtime dia | `snapshot_project` | Export the schematic and PCB to PDF as a timestamped snapshot/checkpoint. Useful before major edits. | | `open_schematic_viewer` | Launch the live schematic viewer (SVG with auto-refresh on file change). Use after placing components so the user can see changes in real time. | -### `editor_navigation` · 2 tools +### `editor_navigation` · 3 tools **Purpose:** Observe and semantically navigate exact KiCad editor, document, sheet, selection, and cross-probe context. **Source:** [`crates/konnect-core/src/tools/editor_navigation.rs`](crates/konnect-core/src/tools/editor_navigation.rs) @@ -69,6 +69,7 @@ Seven tools, grouped into *discovery/routing*, *observability*, and *runtime dia |------|-------------| | `get_editor_state` | Observe the configured KiCad IPC endpoint's running version, addressable schematic/PCB editors, exact open document identities, capability availability, and explicit active-context limitations. | | `get_editor_selection` | Read the selection from one exact editor, project, document, and hierarchical sheet instance with stable KIID/UUID identities and document-readback freshness checks. | +| `resolve_navigation_target` | Resolve an exact open project/document/sheet object by stable KIID, or by a human reference only when saved KiCad structure yields one unambiguous candidate. | --- From d6a78a6beab728a545c42e0ef9260d396805dc05 Mon Sep 17 00:00:00 2001 From: dubesinhower Date: Sun, 30 Aug 2026 13:54:34 -0400 Subject: [PATCH 15/16] feat(navigation): verify editor selection mutations --- DEV.md | 6 +- README.md | 4 +- crates/konnect-core/src/mcp/error.rs | 15 + crates/konnect-core/src/router/registry.rs | 2 +- .../src/tools/editor_navigation.rs | 445 +++++++++++++++++- crates/konnect-ipc/src/client.rs | 151 ++++++ crates/konnect-ipc/src/types.rs | 67 +++ crates/konnect-ipc/tests/mock_server_test.rs | 156 +++++- docs/KICAD_INTEGRATION.md | 8 + docs/TROUBLESHOOTING.md | 2 +- packaging/metadata.json | 2 +- plugin/plugin.json | 2 +- tool-directory.md | 5 +- 13 files changed, 850 insertions(+), 15 deletions(-) diff --git a/DEV.md b/DEV.md index 6ca88a61..ec83fe9a 100644 --- a/DEV.md +++ b/DEV.md @@ -316,7 +316,7 @@ Source: [`crates/konnect-core/src/observability.rs`](crates/konnect-core/src/obs ## Tool Routing (Starter Kit + On-Demand Loading) -The server does NOT expose all 224 tools (231 total with the 7 meta-tools) in `tools/list` by default — that would cost ~23K tokens of context on every listing. Instead: +The server does NOT expose all 225 tools (232 total with the 7 meta-tools) in `tools/list` by default — that would cost ~23K tokens of context on every listing. Instead: - **Startup**: only `STARTER_KIT` toolsets are pre-loaded (see `router/registry.rs::STARTER_KIT`). Currently: `project`, `config`. Combined with the 7 meta-tools, baseline `tools/list` is 21 tools ≈ 2K tokens. - **On demand**: the LLM reads `list_toolboxes` → calls `load_toolset(name)` to expose a toolset's tools in subsequent `tools/list` responses. `unload_toolset(name)` prunes them when the task shifts. @@ -391,9 +391,9 @@ convention for other `kicad-cli`-calling code. ## Current Stats -- **21 toolsets, 224 tools** + 7 meta-tools (4 routing + 2 observability + 1 runtime diagnostic — see `tool-directory.md`) +- **21 toolsets, 225 tools** + 7 meta-tools (4 routing + 2 observability + 1 runtime diagnostic — see `tool-directory.md`) - Baseline `tools/list`: 21 tools / ~2K tokens (starter kit + meta-tools) -- Full-catalog `tools/list` (all loaded): 231 tools (224 registered + 7 meta) / ~25K tokens +- Full-catalog `tools/list` (all loaded): 232 tools (225 registered + 7 meta) / ~25K tokens - **0 IPC stubs** (all protobuf methods implemented) - **0 unimplemented tools** - **Specctra DSN/SES are PCB-editor operations**, not `kicad-cli` commands. diff --git a/README.md b/README.md index d82a7ce1..21dfa178 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Rust binary — that lets Claude and other AI assistants design schematics and PCBs through the [Model Context Protocol](https://modelcontextprotocol.io) (MCP). -**224 tools across 21 on-demand toolsets.** Schematic capture, PCB layout and +**225 tools across 21 on-demand toolsets.** Schematic capture, PCB layout and routing, ERC/DRC, design-review audits, JLCPCB part search, reference circuits, and a full manufacturing export pipeline — with bundled skills and agents that teach Claude KiCAD conventions out of the box. @@ -70,7 +70,7 @@ through its own S-expression engine with atomic writes (write, fsync, rename), U preservation, and round-trip tests — no third-party schematic library with known gaps, no text-manipulation workarounds. -**Context economy is a feature.** Exposing all 224 tools to an LLM costs roughly 23K +**Context economy is a feature.** Exposing all 225 tools to an LLM costs roughly 23K tokens of context on every listing. Konnect's router loads a starter kit (~2K tokens) and lets the model pull in toolsets on demand — plus built-in observability (`get_recent_calls`, `server_stats`, JSONL call logs) so the model can diagnose its diff --git a/crates/konnect-core/src/mcp/error.rs b/crates/konnect-core/src/mcp/error.rs index 94cad6b1..40b1ca5f 100644 --- a/crates/konnect-core/src/mcp/error.rs +++ b/crates/konnect-core/src/mcp/error.rs @@ -86,6 +86,14 @@ pub enum ToolErrorKind { capability: String, kicad_version: Option, }, + /// KiCad accepted a semantic mutation, but a fresh observation did not + /// prove the exact requested post-operation state. + ReadbackMismatch { + operation: String, + requested_kiids: Vec, + before_kiids: Vec, + after_kiids: Vec, + }, /// A board was live earlier in this server process, but IPC is now gone; /// its saved file may be stale relative to lost editor state. UnsafeFileFallback { path: String }, @@ -112,6 +120,7 @@ impl ToolErrorKind { Self::StaleTarget { .. } => "stale_target", Self::EditorUnavailable { .. } => "editor_unavailable", Self::UnsupportedCapability { .. } => "unsupported_capability", + Self::ReadbackMismatch { .. } => "readback_mismatch", Self::UnsafeFileFallback { .. } => "unsafe_file_fallback", Self::HandlerError { .. } => "handler_error", } @@ -240,6 +249,12 @@ mod tests { capability: "activate_sheet".into(), kicad_version: Some("10.0.5".into()), }, + ToolErrorKind::ReadbackMismatch { + operation: "add".into(), + requested_kiids: vec!["b".into()], + before_kiids: vec!["a".into()], + after_kiids: vec!["a".into()], + }, ToolErrorKind::UnsafeFileFallback { path: "p".into() }, ToolErrorKind::HandlerError { reason: "r".into() }, ]; diff --git a/crates/konnect-core/src/router/registry.rs b/crates/konnect-core/src/router/registry.rs index c20f9166..c227def1 100644 --- a/crates/konnect-core/src/router/registry.rs +++ b/crates/konnect-core/src/router/registry.rs @@ -28,7 +28,7 @@ pub static ALL_TOOLSETS: &[ToolsetMeta] = &[ name: "editor_navigation", description: "Observe and semantically navigate exact KiCad editor, document, sheet, selection, and cross-probe context", category: "project", - tool_count: 3, + tool_count: 4, }, ToolsetMeta { name: "sch_components", diff --git a/crates/konnect-core/src/tools/editor_navigation.rs b/crates/konnect-core/src/tools/editor_navigation.rs index 21a9f15d..60c6d759 100644 --- a/crates/konnect-core/src/tools/editor_navigation.rs +++ b/crates/konnect-core/src/tools/editor_navigation.rs @@ -9,8 +9,8 @@ use crate::mcp::{error::ToolErrorKind, protocol::CallToolResult}; use crate::tool; use crate::tools::{invalid_arg, opt_str, require_array, require_str, ToolContext, ToolDef}; use konnect_ipc::{ - IpcEditorDocument, IpcEditorKind, IpcProjectIdentity, IpcSelectionObservationErrorKind, - IpcSheetInstancePath, + IpcEditorDocument, IpcEditorKind, IpcProjectIdentity, IpcSelectionMutation, + IpcSelectionMutationErrorKind, IpcSelectionObservationErrorKind, IpcSheetInstancePath, }; use serde_json::json; use std::path::PathBuf; @@ -71,6 +71,25 @@ pub fn tools() -> Vec { }), |args, ctx| async move { handle_resolve_navigation_target(args, ctx).await } ), + tool!( + "mutate_editor_selection", + "Clear, add to, or remove from one exact KiCad editor selection. Every non-clear KIID is first resolved in the explicit saved project/document/sheet, and success is derived only from a fresh exact GetSelection readback.", + json!({ + "type": "object", + "properties": { + "operation": { "type": "string", "enum": ["clear", "add", "remove"] }, + "editor": { "type": "string", "enum": ["schematic", "pcb"] }, + "project_name": { "type": "string" }, + "project_path": { "type": "string" }, + "document_path": { "type": "string", "description": "Exact saved .kicad_sch or .kicad_pcb document" }, + "sheet_instance_path": { "type": "array", "items": { "type": "string" } }, + "sheet_path_human_readable": { "type": "string" }, + "object_kiids": { "type": "array", "items": { "type": "string" }, "description": "Empty for clear; one or more stable KIIIDs for add/remove" } + }, + "required": ["operation", "editor", "project_name", "project_path", "document_path", "object_kiids"] + }), + |args, ctx| async move { handle_mutate_editor_selection(args, ctx).await } + ), ] } @@ -452,6 +471,222 @@ fn navigation_target_error_result( CallToolResult::error_kind(kind, error.to_string()) } +#[derive(Debug)] +struct SelectionMutationRequest { + operation: IpcSelectionMutation, + live_document: IpcEditorDocument, + object_kiids: Vec, + structural_targets: Vec, +} + +async fn handle_mutate_editor_selection( + args: &serde_json::Value, + ctx: &ToolContext, +) -> anyhow::Result { + let request = match parse_selection_mutation_request(args) { + Ok(request) => request, + Err(result) => return Ok(result), + }; + let address = ctx.config.ipc_address.clone(); + if address.is_empty() { + return Ok(editor_unavailable("no KiCad IPC endpoint is configured")); + } + let result = tokio::task::spawn_blocking(move || -> anyhow::Result<_> { + let resolved_targets = request + .structural_targets + .iter() + .map(resolve_navigation_target) + .collect::, _>>()?; + let mutation = konnect_ipc::KiCadIpcClient::new(address).mutate_selection( + &request.live_document, + request.operation, + &request.object_kiids, + )?; + Ok((resolved_targets, mutation)) + }) + .await?; + match result { + Ok((resolved_targets, mutation)) => Ok(CallToolResult::json(&json!({ + "resolved_targets": resolved_targets, + "mutation": mutation + }))), + Err(error) => Ok(selection_mutation_error_result(error)), + } +} + +fn parse_selection_mutation_request( + args: &serde_json::Value, +) -> Result { + let operation = match require_str(args, "operation")? { + "clear" => IpcSelectionMutation::Clear, + "add" => IpcSelectionMutation::Add, + "remove" => IpcSelectionMutation::Remove, + _ => { + return Err(invalid_arg( + "operation", + "expected 'clear', 'add', or 'remove'", + )) + } + }; + let editor = match require_str(args, "editor")? { + "schematic" => IpcEditorKind::Schematic, + "pcb" => IpcEditorKind::Pcb, + _ => return Err(invalid_arg("editor", "expected 'schematic' or 'pcb'")), + }; + let project_name = require_str(args, "project_name")?; + let project_path = require_str(args, "project_path")?; + let document_path = require_str(args, "document_path")?; + if project_name.is_empty() || project_path.is_empty() || document_path.is_empty() { + return Err(invalid_arg( + "project_name", + "project and document identity strings must not be empty", + )); + } + let object_kiids = require_array(args, "object_kiids")? + .iter() + .map(|value| value.as_str().map(str::to_string)) + .collect::>>() + .ok_or_else(|| invalid_arg("object_kiids", "every entry must be a string"))?; + if object_kiids.iter().any(String::is_empty) { + return Err(invalid_arg("object_kiids", "KIIIDs must not be empty")); + } + let unique = object_kiids + .iter() + .collect::>(); + if unique.len() != object_kiids.len() { + return Err(invalid_arg( + "object_kiids", + "duplicate KIIIDs are not allowed", + )); + } + match operation { + IpcSelectionMutation::Clear if !object_kiids.is_empty() => { + return Err(invalid_arg("object_kiids", "clear requires an empty array")); + } + IpcSelectionMutation::Add | IpcSelectionMutation::Remove if object_kiids.is_empty() => { + return Err(invalid_arg( + "object_kiids", + "add and remove require at least one KIID", + )); + } + _ => {} + } + + let project = IpcProjectIdentity { + name: project_name.to_string(), + path: project_path.to_string(), + }; + let sheet_instance_path = match editor { + IpcEditorKind::Pcb => { + if !args["sheet_instance_path"].is_null() + || !args["sheet_path_human_readable"].is_null() + { + return Err(invalid_arg( + "sheet_instance_path", + "PCB targets cannot carry schematic sheet identity", + )); + } + None + } + IpcEditorKind::Schematic => { + let ids = require_array(args, "sheet_instance_path")? + .iter() + .map(|value| value.as_str().map(str::to_string)) + .collect::>>() + .ok_or_else(|| { + invalid_arg("sheet_instance_path", "every entry must be a string") + })?; + if ids.is_empty() || ids.iter().any(String::is_empty) { + return Err(invalid_arg( + "sheet_instance_path", + "must contain non-empty root-to-leaf KIIIDs", + )); + } + Some(IpcSheetInstancePath { + kiids: ids, + human_readable: opt_str(args, "sheet_path_human_readable") + .unwrap_or("") + .to_string(), + }) + } + }; + let saved_document = PathBuf::from(document_path); + let structural_targets = object_kiids + .iter() + .map(|kiid| NavigationTargetRequest { + editor, + project: project.clone(), + document_path: saved_document.clone(), + sheet_instance_path: sheet_instance_path.clone(), + object_kiid: Some(kiid.clone()), + human_reference: None, + }) + .collect(); + Ok(SelectionMutationRequest { + operation, + live_document: IpcEditorDocument { + editor, + project: Some(project), + document_path: (editor == IpcEditorKind::Pcb) + .then(|| saved_document.display().to_string()), + sheet_instance_path, + }, + object_kiids, + structural_targets, + }) +} + +fn selection_mutation_error_result(error: anyhow::Error) -> CallToolResult { + if let Some(target) = error + .chain() + .find_map(|cause| cause.downcast_ref::()) + { + return navigation_target_error_result(target.clone()); + } + if let Some(mutation) = konnect_ipc::IpcSelectionMutationError::from_error(&error) { + let kind = match mutation.kind { + IpcSelectionMutationErrorKind::InvalidRequest => ToolErrorKind::InvalidArgument { + field: "object_kiids".to_string(), + reason: mutation.reason.clone(), + }, + IpcSelectionMutationErrorKind::ReadbackMismatch => ToolErrorKind::ReadbackMismatch { + operation: mutation.operation.as_str().to_string(), + requested_kiids: mutation.requested_kiids.clone(), + before_kiids: mutation.before_kiids.clone(), + after_kiids: mutation.after_kiids.clone(), + }, + }; + return CallToolResult::error_kind(kind, mutation.to_string()); + } + if konnect_ipc::IpcSelectionObservationError::from_error(&error).is_some() { + return selection_error_result(error); + } + if let Some(status) = konnect_ipc::ApiStatusError::from_error(&error) { + if status.is_unsupported() { + return CallToolResult::error_kind( + ToolErrorKind::UnsupportedCapability { + capability: "selection_mutation".to_string(), + kicad_version: None, + }, + "The running KiCad endpoint does not support typed selection mutation.", + ); + } + } + match konnect_ipc::IpcFailure::from_error(error) { + konnect_ipc::IpcFailure::Unreachable(_) => { + editor_unavailable("the configured KiCad IPC endpoint is unreachable") + } + _ => CallToolResult::error_kind( + ToolErrorKind::StaleTarget { + target: "requested editor selection mutation".to_string(), + reason: "KiCad did not return a complete typed mutation/readback sequence" + .to_string(), + }, + "KiCad did not return a complete typed selection mutation/readback sequence.", + ), + } +} + #[cfg(test)] mod tests { use super::*; @@ -644,10 +879,87 @@ mod tests { url } + fn spawn_add_selection_mock(project_path: String, apply_mutation: bool) -> String { + static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let url = format!( + "inproc://selection-mutation-core-{}", + NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + ); + let socket = nng::Socket::new(nng::Protocol::Rep0).expect("mock socket"); + socket.listen(&url).expect("listen"); + std::thread::spawn(move || { + let mut selected = false; + for _ in 0..8 { + let message = socket.recv().expect("request"); + let request = + kiapi::common::ApiRequest::decode(message.as_slice()).expect("decode request"); + let command = request.message.expect("command"); + let response_any = if command.type_url.ends_with("GetOpenDocuments") { + builders::pack_any( + &kiapi::common::commands::GetOpenDocumentsResponse { + documents: vec![kiapi::common::types::DocumentSpecifier { + r#type: kiapi::common::types::DocumentType::DoctypePcb as i32, + identifier: Some( + kiapi::common::types::document_specifier::Identifier::BoardFilename( + "layout.kicad_pcb".to_string(), + ), + ), + project: Some(kiapi::common::types::ProjectSpecifier { + name: "nav".to_string(), + path: project_path.clone(), + }), + }], + }, + "kiapi.common.commands.GetOpenDocumentsResponse", + ) + } else { + if command.type_url.ends_with("AddToSelection") && apply_mutation { + let add = kiapi::common::commands::AddToSelection::decode( + command.value.as_slice(), + ) + .expect("add selection"); + assert_eq!(add.items[0].value, "fp-c10"); + selected = true; + } + let items = selected + .then(|| { + builders::pack_any( + &kiapi::board::types::FootprintInstance { + id: Some(kiapi::common::types::Kiid { + value: "fp-c10".to_string(), + }), + ..Default::default() + }, + "kiapi.board.types.FootprintInstance", + ) + }) + .into_iter() + .collect(); + builders::pack_any( + &kiapi::common::commands::SelectionResponse { items }, + "kiapi.common.commands.SelectionResponse", + ) + }; + let response = kiapi::common::ApiResponse { + status: Some(kiapi::common::ApiResponseStatus { + status: kiapi::common::ApiStatusCode::AsOk as i32, + error_message: String::new(), + }), + header: None, + message: Some(response_any), + }; + socket + .send(nng::Message::from(response.encode_to_vec().as_slice())) + .expect("response"); + } + }); + url + } + #[test] fn public_tool_is_read_only_and_takes_no_required_arguments() { let definitions = tools(); - assert_eq!(definitions.len(), 3); + assert_eq!(definitions.len(), 4); let state = definitions .iter() .find(|tool| tool.name == "get_editor_state") @@ -669,6 +981,21 @@ mod tests { resolver.input_schema["required"], json!(["editor", "project_name", "project_path", "document_path"]) ); + let mutation = definitions + .iter() + .find(|tool| tool.name == "mutate_editor_selection") + .expect("selection mutation tool"); + assert_eq!( + mutation.input_schema["required"], + json!([ + "operation", + "editor", + "project_name", + "project_path", + "document_path", + "object_kiids" + ]) + ); } #[tokio::test] @@ -836,4 +1163,116 @@ mod tests { "kicad_ipc_get_open_documents" ); } + + #[test] + fn mutation_parser_requires_operation_appropriate_unique_kiids() { + let base = json!({ + "operation": "clear", + "editor": "pcb", + "project_name": "navigation", + "project_path": r"C:\design", + "document_path": r"C:\design\navigation.kicad_pcb", + "object_kiids": [] + }); + assert!(parse_selection_mutation_request(&base).is_ok()); + + for object_kiids in [json!(["a"]), json!(["a", "a"])] { + let mut invalid = base.clone(); + invalid["object_kiids"] = object_kiids; + if invalid["object_kiids"].as_array().map(Vec::len) == Some(2) { + invalid["operation"] = json!("add"); + } + let result = parse_selection_mutation_request(&invalid).expect_err("invalid KIIIDs"); + assert_eq!( + extract_error_kind(&result).as_deref(), + Some("invalid_argument") + ); + } + } + + #[tokio::test] + async fn public_selection_mutation_success_is_derived_from_readback() { + let temp = tempfile::tempdir().unwrap(); + std::fs::write(temp.path().join("nav.kicad_pro"), "{}").unwrap(); + let board = temp.path().join("layout.kicad_pcb"); + std::fs::write( + &board, + "(kicad_pcb (footprint \"Capacitor:C\" (layer \"F.Cu\") (at 1 2) \ + (uuid \"fp-c10\") (property \"Reference\" \"C10\")))", + ) + .unwrap(); + let project_path = temp.path().display().to_string(); + let result = handle_mutate_editor_selection( + &json!({ + "operation": "add", + "editor": "pcb", + "project_name": "nav", + "project_path": project_path.clone(), + "document_path": board.display().to_string(), + "object_kiids": ["fp-c10"] + }), + &context(spawn_add_selection_mock(project_path, true)), + ) + .await + .expect("handler result"); + assert!(!result.is_error); + let ToolContent::Text { text } = &result.content[0] else { + panic!("expected text result"); + }; + let body: serde_json::Value = serde_json::from_str(text).unwrap(); + assert_eq!(body["resolved_targets"][0]["object"]["kiid"], "fp-c10"); + assert_eq!( + body["mutation"]["after"]["selected_objects"][0]["kiid"], + "fp-c10" + ); + assert_eq!( + body["mutation"]["evidence_source"], + "kicad_ipc_selection_mutation_with_get_selection_readback" + ); + } + + #[tokio::test] + async fn public_selection_mutation_reports_readback_mismatch() { + let temp = tempfile::tempdir().unwrap(); + std::fs::write(temp.path().join("nav.kicad_pro"), "{}").unwrap(); + let board = temp.path().join("layout.kicad_pcb"); + std::fs::write( + &board, + "(kicad_pcb (footprint \"Capacitor:C\" (layer \"F.Cu\") (at 1 2) \ + (uuid \"fp-c10\") (property \"Reference\" \"C10\")))", + ) + .unwrap(); + let project_path = temp.path().display().to_string(); + let result = handle_mutate_editor_selection( + &json!({ + "operation": "add", + "editor": "pcb", + "project_name": "nav", + "project_path": project_path.clone(), + "document_path": board.display().to_string(), + "object_kiids": ["fp-c10"] + }), + &context(spawn_add_selection_mock(project_path, false)), + ) + .await + .expect("handler result"); + assert_eq!( + extract_error_kind(&result).as_deref(), + Some("readback_mismatch") + ); + } + + #[test] + fn unsupported_selection_mutation_maps_to_a_typed_capability_refusal() { + let result = + selection_mutation_error_result(anyhow::Error::new(konnect_ipc::ApiStatusError { + code: kiapi::common::ApiStatusCode::AsUnhandled as i32, + code_name: "AS_UNHANDLED".to_string(), + message: "unsupported".to_string(), + })); + assert_eq!( + extract_error_kind(&result).as_deref(), + Some("unsupported_capability") + ); + } } diff --git a/crates/konnect-ipc/src/client.rs b/crates/konnect-ipc/src/client.rs index fa880b18..8671d823 100644 --- a/crates/konnect-ipc/src/client.rs +++ b/crates/konnect-ipc/src/client.rs @@ -13,6 +13,7 @@ use crate::types::*; use anyhow::{Context, Result}; // NNG SetOpt trait is brought in scope automatically by the nng crate's prelude use prost::Message; +use std::collections::BTreeSet; use std::path::{Path, PathBuf}; use tracing::{debug, warn}; @@ -777,6 +778,128 @@ impl KiCadIpcClient { }) } + /// Mutate one exact editor selection and prove the complete resulting set + /// through a fresh typed `GetSelection` observation. + pub fn mutate_selection( + &self, + requested: &IpcEditorDocument, + operation: IpcSelectionMutation, + requested_kiids: &[String], + ) -> Result { + let mut unique = BTreeSet::new(); + if requested_kiids.iter().any(|kiid| kiid.is_empty()) { + return Err(selection_mutation_error( + operation, + requested_kiids, + Vec::new(), + Vec::new(), + IpcSelectionMutationErrorKind::InvalidRequest, + "selection KIIDs must not be empty", + )); + } + if requested_kiids.iter().any(|kiid| !unique.insert(kiid)) { + return Err(selection_mutation_error( + operation, + requested_kiids, + Vec::new(), + Vec::new(), + IpcSelectionMutationErrorKind::InvalidRequest, + "selection mutation contains a duplicate KIID", + )); + } + match operation { + IpcSelectionMutation::Clear if !requested_kiids.is_empty() => { + return Err(selection_mutation_error( + operation, + requested_kiids, + Vec::new(), + Vec::new(), + IpcSelectionMutationErrorKind::InvalidRequest, + "clear selection does not accept object KIIDs", + )); + } + IpcSelectionMutation::Add | IpcSelectionMutation::Remove + if requested_kiids.is_empty() => + { + return Err(selection_mutation_error( + operation, + requested_kiids, + Vec::new(), + Vec::new(), + IpcSelectionMutationErrorKind::InvalidRequest, + "add and remove selection require at least one object KIID", + )); + } + _ => {} + } + + let before = self.observe_selection(requested)?; + let document = self.resolve_selection_document(requested)?; + let items = requested_kiids + .iter() + .map(|kiid| kiapi::common::types::Kiid { + value: kiid.clone(), + }) + .collect::>(); + let response = match operation { + IpcSelectionMutation::Clear => self.send_command( + &kiapi::common::commands::ClearSelection { + header: Some(header_for(document)), + }, + "kiapi.common.commands.ClearSelection", + )?, + IpcSelectionMutation::Add => self.send_command( + &kiapi::common::commands::AddToSelection { + header: Some(header_for(document)), + items, + }, + "kiapi.common.commands.AddToSelection", + )?, + IpcSelectionMutation::Remove => self.send_command( + &kiapi::common::commands::RemoveFromSelection { + header: Some(header_for(document)), + items, + }, + "kiapi.common.commands.RemoveFromSelection", + )?, + }; + let _: kiapi::common::commands::SelectionResponse = + unpack_required(response, "selection mutation")?; + let after = self.observe_selection(requested)?; + + let before_kiids = selection_kiids(&before); + let after_kiids = selection_kiids(&after); + let mut expected = before_kiids.iter().cloned().collect::>(); + match operation { + IpcSelectionMutation::Clear => expected.clear(), + IpcSelectionMutation::Add => expected.extend(requested_kiids.iter().cloned()), + IpcSelectionMutation::Remove => { + for kiid in requested_kiids { + expected.remove(kiid); + } + } + } + let expected_kiids = expected.into_iter().collect::>(); + if expected_kiids != after_kiids { + return Err(selection_mutation_error( + operation, + requested_kiids, + before_kiids, + after_kiids, + IpcSelectionMutationErrorKind::ReadbackMismatch, + "post-operation GetSelection did not match the requested exact set transition", + )); + } + + Ok(IpcSelectionMutationResult { + operation, + requested_kiids: requested_kiids.to_vec(), + before, + after, + evidence_source: "kicad_ipc_selection_mutation_with_get_selection_readback".to_string(), + }) + } + /// Prove that one exact editor/document/sheet identity is currently open. /// /// This is the read-only context gate used by semantic target resolution; @@ -3290,6 +3413,34 @@ fn malformed_selected_object( }) } +fn selection_kiids(observation: &IpcSelectionObservation) -> Vec { + let mut kiids = observation + .selected_objects + .iter() + .map(|object| object.kiid.clone()) + .collect::>(); + kiids.sort(); + kiids +} + +fn selection_mutation_error( + operation: IpcSelectionMutation, + requested_kiids: &[String], + before_kiids: Vec, + after_kiids: Vec, + kind: IpcSelectionMutationErrorKind, + reason: &str, +) -> anyhow::Error { + anyhow::Error::new(IpcSelectionMutationError { + kind, + operation, + requested_kiids: requested_kiids.to_vec(), + before_kiids, + after_kiids, + reason: reason.to_string(), + }) +} + fn editor_capabilities( editor: IpcEditorKind, version: &IpcKiCadVersion, diff --git a/crates/konnect-ipc/src/types.rs b/crates/konnect-ipc/src/types.rs index 23884413..e436bf5e 100644 --- a/crates/konnect-ipc/src/types.rs +++ b/crates/konnect-ipc/src/types.rs @@ -132,6 +132,73 @@ pub struct IpcSelectionObservation { pub evidence_source: String, } +/// Semantic selection change requested from one exact editor context. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum IpcSelectionMutation { + Clear, + Add, + Remove, +} + +impl IpcSelectionMutation { + pub fn as_str(self) -> &'static str { + match self { + Self::Clear => "clear", + Self::Add => "add", + Self::Remove => "remove", + } + } +} + +/// Verified selection mutation. Success means the post-operation observation +/// exactly matched the requested set transition, not merely that IPC replied. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct IpcSelectionMutationResult { + pub operation: IpcSelectionMutation, + pub requested_kiids: Vec, + pub before: IpcSelectionObservation, + pub after: IpcSelectionObservation, + pub evidence_source: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IpcSelectionMutationErrorKind { + InvalidRequest, + ReadbackMismatch, +} + +/// A semantic selection mutation was invalid or its observed result did not +/// prove the requested change. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IpcSelectionMutationError { + pub kind: IpcSelectionMutationErrorKind, + pub operation: IpcSelectionMutation, + pub requested_kiids: Vec, + pub before_kiids: Vec, + pub after_kiids: Vec, + pub reason: String, +} + +impl IpcSelectionMutationError { + pub fn from_error(error: &anyhow::Error) -> Option<&Self> { + error.chain().find_map(|cause| cause.downcast_ref::()) + } +} + +impl std::fmt::Display for IpcSelectionMutationError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + formatter, + "cannot verify {} selection mutation: {}", + self.operation.as_str(), + self.reason + ) + } +} + +impl std::error::Error for IpcSelectionMutationError {} + /// Stable classification for a fail-closed selection observation. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum IpcSelectionObservationErrorKind { diff --git a/crates/konnect-ipc/tests/mock_server_test.rs b/crates/konnect-ipc/tests/mock_server_test.rs index 9541a6bb..43343532 100644 --- a/crates/konnect-ipc/tests/mock_server_test.rs +++ b/crates/konnect-ipc/tests/mock_server_test.rs @@ -8,7 +8,8 @@ use konnect_ipc::builders; use konnect_ipc::gen::kiapi; use konnect_ipc::{ - IpcEditorDocument, IpcEditorKind, IpcProjectIdentity, IpcSelectionObservationError, + IpcEditorDocument, IpcEditorKind, IpcProjectIdentity, IpcSelectionMutation, + IpcSelectionMutationError, IpcSelectionMutationErrorKind, IpcSelectionObservationError, IpcSelectionObservationErrorKind, IpcSheetInstancePath, KiCadIpcClient, }; use nng::options::Options; @@ -2113,3 +2114,156 @@ fn document_disappearing_during_selection_readback_is_stale_editor_state() { IpcSelectionObservationErrorKind::StaleEditorState ); } + +fn selected_footprints(ids: &std::collections::BTreeSet) -> Vec { + ids.iter() + .map(|id| { + builders::pack_any( + &kiapi::board::types::FootprintInstance { + id: Some(kiid(id)), + ..Default::default() + }, + "kiapi.board.types.FootprintInstance", + ) + }) + .collect() +} + +fn spawn_selection_mutation_mock(initial: &[&str], apply_mutation: bool) -> MockKicad { + let selection = Arc::new(Mutex::new( + initial + .iter() + .map(|id| (*id).to_string()) + .collect::>(), + )); + let selection_in_mock = selection.clone(); + spawn_mock(move |request| { + let message = request.message.expect("command"); + if message.type_url.ends_with("GetOpenDocuments") { + return Some(open_navigation_documents_response(vec![ + navigation_board_document(), + ])); + } + if message.type_url.ends_with("GetSelection") { + return Some(selection_response(selected_footprints( + &selection_in_mock.lock().unwrap(), + ))); + } + + let mutation_header = if message.type_url.ends_with("ClearSelection") { + let command = + kiapi::common::commands::ClearSelection::decode(message.value.as_slice()).unwrap(); + if apply_mutation { + selection_in_mock.lock().unwrap().clear(); + } + command.header + } else if message.type_url.ends_with("AddToSelection") { + let command = + kiapi::common::commands::AddToSelection::decode(message.value.as_slice()).unwrap(); + if apply_mutation { + selection_in_mock + .lock() + .unwrap() + .extend(command.items.iter().map(|id| id.value.clone())); + } + command.header + } else if message.type_url.ends_with("RemoveFromSelection") { + let command = + kiapi::common::commands::RemoveFromSelection::decode(message.value.as_slice()) + .unwrap(); + if apply_mutation { + let mut selection = selection_in_mock.lock().unwrap(); + for id in &command.items { + selection.remove(&id.value); + } + } + command.header + } else { + panic!("unexpected request {}", message.type_url); + }; + let document = mutation_header + .as_ref() + .and_then(|header| header.document.as_ref()) + .expect("selection mutation document"); + assert_eq!(board_filename(document), "navigation.kicad_pcb"); + Some(selection_response(selected_footprints( + &selection_in_mock.lock().unwrap(), + ))) + }) +} + +#[test] +fn clear_add_and_remove_selection_are_proven_by_exact_readback() { + let cases = [ + (IpcSelectionMutation::Clear, vec!["a"], Vec::::new()), + ( + IpcSelectionMutation::Add, + vec!["a"], + vec!["a".to_string(), "b".to_string()], + ), + ( + IpcSelectionMutation::Remove, + vec!["a", "b"], + vec!["a".to_string()], + ), + ]; + for (operation, initial, expected) in cases { + let mock = spawn_selection_mutation_mock(&initial, true); + let requested = match operation { + IpcSelectionMutation::Clear => Vec::new(), + IpcSelectionMutation::Add | IpcSelectionMutation::Remove => vec!["b".to_string()], + }; + let result = KiCadIpcClient::new(&mock.url) + .mutate_selection(&navigation_board_target(), operation, &requested) + .expect("verified selection mutation"); + let mut observed = result + .after + .selected_objects + .iter() + .map(|object| object.kiid.clone()) + .collect::>(); + observed.sort(); + assert_eq!(observed, expected); + assert_eq!(result.operation, operation); + assert!(result.evidence_source.contains("get_selection_readback")); + } +} + +#[test] +fn transport_success_without_the_requested_selection_change_is_a_mismatch() { + let mock = spawn_selection_mutation_mock(&["a"], false); + let error = KiCadIpcClient::new(&mock.url) + .mutate_selection( + &navigation_board_target(), + IpcSelectionMutation::Add, + &["b".to_string()], + ) + .expect_err("unchanged readback is not success"); + let typed = IpcSelectionMutationError::from_error(&error).expect("typed mutation error"); + assert_eq!(typed.kind, IpcSelectionMutationErrorKind::ReadbackMismatch); + assert_eq!(typed.before_kiids, ["a"]); + assert_eq!(typed.after_kiids, ["a"]); +} + +#[test] +fn invalid_selection_mutations_are_rejected_before_transport() { + let client = KiCadIpcClient::new("inproc://not-contacted"); + for (operation, requested) in [ + (IpcSelectionMutation::Clear, vec!["a".to_string()]), + (IpcSelectionMutation::Add, Vec::new()), + ( + IpcSelectionMutation::Remove, + vec!["a".to_string(), "a".to_string()], + ), + ] { + let error = client + .mutate_selection(&navigation_board_target(), operation, &requested) + .expect_err("invalid request"); + assert_eq!( + IpcSelectionMutationError::from_error(&error) + .expect("typed mutation error") + .kind, + IpcSelectionMutationErrorKind::InvalidRequest + ); + } +} diff --git a/docs/KICAD_INTEGRATION.md b/docs/KICAD_INTEGRATION.md index 1e55564c..5378ad21 100644 --- a/docs/KICAD_INTEGRATION.md +++ b/docs/KICAD_INTEGRATION.md @@ -71,6 +71,14 @@ object matches in the exact document and sheet instance; duplicates return structured candidates, and stale project ownership or symbol-instance paths fail closed before any editor mutation. +Selection mutation uses KiCad's typed `ClearSelection`, `AddToSelection`, and +`RemoveFromSelection` commands only after every non-clear KIID resolves in the +explicit saved project/document/sheet. The transport response is not treated +as success: Konnect performs a fresh exact-context `GetSelection`, compares the +entire observed set with the expected before/after transition, and returns a +structured `readback_mismatch` if KiCad did not make precisely that change. +Duplicate or empty KIID requests are rejected before IPC. + ## Schematic-To-Board Sync `update_pcb_from_schematic` in `tools/pcb_sync.rs` is live-IPC-only. It uses diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index e39de72e..38fd26f0 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -265,7 +265,7 @@ callable tools, the fix is to make the *first* listing complete: ``` in `konnect.toml` in the working directory, or a `settings.json` beside the binary. Every toolset is then loaded at -startup, so `tools/list` carries all 231 tools from the first call. +startup, so `tools/list` carries all 232 tools from the first call. It is off by default because it costs what the router exists to save: roughly 25K tokens per listing instead of ~2K. Turn it on only if your client needs it. diff --git a/packaging/metadata.json b/packaging/metadata.json index 6e43cca7..ec983ee3 100644 --- a/packaging/metadata.json +++ b/packaging/metadata.json @@ -1,7 +1,7 @@ { "$schema": "https://go.kicad.org/pcm/schemas/v1", "name": "Konnect", - "description": "AI-assisted PCB design via the Model Context Protocol. Enables Claude and other AI assistants to design schematics and PCBs with 224 tools organized into on-demand toolsets.", + "description": "AI-assisted PCB design via the Model Context Protocol. Enables Claude and other AI assistants to design schematics and PCBs with 225 tools organized into on-demand toolsets.", "description_full": "Konnect exposes a complete set of KiCAD design tools to AI assistants via the Model Context Protocol (MCP). It supports schematic editing, PCB layout, local Freerouting MCP routing, library management, JLCPCB part search, ERC/DRC, design review audits, and full export pipelines. Tools are organized into 21 toolsets loaded on demand so the AI only sees relevant tools at once.", "identifier": "com.github.mixelpixx.konnect", "type": "plugin", diff --git a/plugin/plugin.json b/plugin/plugin.json index db7c9142..2ccedd63 100644 --- a/plugin/plugin.json +++ b/plugin/plugin.json @@ -1,7 +1,7 @@ { "identifier": "com.github.mixelpixx.konnect", "name": "Konnect", - "description": "AI-assisted PCB design via the Model Context Protocol. 224 tools for schematic editing, PCB layout, routing, design review, and manufacturing export.", + "description": "AI-assisted PCB design via the Model Context Protocol. 225 tools for schematic editing, PCB layout, routing, design review, and manufacturing export.", "runtime": { "type": "exec" }, diff --git a/tool-directory.md b/tool-directory.md index a585ed4c..90eae97c 100644 --- a/tool-directory.md +++ b/tool-directory.md @@ -13,7 +13,7 @@ Compatibility notes for removed or narrowed arguments are recorded in ## Overview - **21 toolsets** organized into 10 categories -- **224 registered tools** + **7 always-visible meta-tools** = **231 total** +- **225 registered tools** + **7 always-visible meta-tools** = **232 total** - **Discovery pattern**: the server pre-loads only the **starter kit** (`project`, `config`) so baseline `tools/list` costs ~2K tokens instead of ~23K. The LLM reads `list_toolboxes` → calls `load_toolset(name)` to expose additional tools on demand; `unload_toolset(name)` prunes them. `tools/list_changed` is notified on every mutation. If the LLM calls a tool whose toolset isn't loaded, the error names the owning toolset so recovery is a single `load_toolset` hop. `load_toolset` also accepts an array of names to load several toolsets with a single `tools/list` refresh. - **Observability**: every `tools/call` is recorded — ring buffer of the last 100 calls + per-tool counters + JSONL at `/logs/calls.jsonl`. The LLM self-diagnoses via `get_recent_calls` and `server_stats`. @@ -61,7 +61,7 @@ Seven tools, grouped into *discovery/routing*, *observability*, and *runtime dia | `snapshot_project` | Export the schematic and PCB to PDF as a timestamped snapshot/checkpoint. Useful before major edits. | | `open_schematic_viewer` | Launch the live schematic viewer (SVG with auto-refresh on file change). Use after placing components so the user can see changes in real time. | -### `editor_navigation` · 3 tools +### `editor_navigation` · 4 tools **Purpose:** Observe and semantically navigate exact KiCad editor, document, sheet, selection, and cross-probe context. **Source:** [`crates/konnect-core/src/tools/editor_navigation.rs`](crates/konnect-core/src/tools/editor_navigation.rs) @@ -70,6 +70,7 @@ Seven tools, grouped into *discovery/routing*, *observability*, and *runtime dia | `get_editor_state` | Observe the configured KiCad IPC endpoint's running version, addressable schematic/PCB editors, exact open document identities, capability availability, and explicit active-context limitations. | | `get_editor_selection` | Read the selection from one exact editor, project, document, and hierarchical sheet instance with stable KIID/UUID identities and document-readback freshness checks. | | `resolve_navigation_target` | Resolve an exact open project/document/sheet object by stable KIID, or by a human reference only when saved KiCad structure yields one unambiguous candidate. | +| `mutate_editor_selection` | Clear, add to, or remove from one exact editor selection after saved-object validation; report success only when a fresh typed selection readback proves the complete requested set transition. | --- From e87914e6b19ba8925fc8b6685417de41e96e240b Mon Sep 17 00:00:00 2001 From: dubesinhower Date: Sun, 30 Aug 2026 14:03:46 -0400 Subject: [PATCH 16/16] feat(navigation): gate editor activation capabilities --- DEV.md | 6 +- README.md | 4 +- crates/konnect-core/src/router/registry.rs | 2 +- .../src/tools/editor_navigation.rs | 410 +++++++++++++++++- crates/konnect-ipc/src/client.rs | 4 +- crates/konnect-ipc/src/types.rs | 2 + docs/KICAD_INTEGRATION.md | 9 + docs/TROUBLESHOOTING.md | 2 +- packaging/metadata.json | 2 +- plugin/plugin.json | 2 +- tool-directory.md | 5 +- 11 files changed, 432 insertions(+), 16 deletions(-) diff --git a/DEV.md b/DEV.md index ec83fe9a..57fa6b92 100644 --- a/DEV.md +++ b/DEV.md @@ -316,7 +316,7 @@ Source: [`crates/konnect-core/src/observability.rs`](crates/konnect-core/src/obs ## Tool Routing (Starter Kit + On-Demand Loading) -The server does NOT expose all 225 tools (232 total with the 7 meta-tools) in `tools/list` by default — that would cost ~23K tokens of context on every listing. Instead: +The server does NOT expose all 226 tools (233 total with the 7 meta-tools) in `tools/list` by default — that would cost ~23K tokens of context on every listing. Instead: - **Startup**: only `STARTER_KIT` toolsets are pre-loaded (see `router/registry.rs::STARTER_KIT`). Currently: `project`, `config`. Combined with the 7 meta-tools, baseline `tools/list` is 21 tools ≈ 2K tokens. - **On demand**: the LLM reads `list_toolboxes` → calls `load_toolset(name)` to expose a toolset's tools in subsequent `tools/list` responses. `unload_toolset(name)` prunes them when the task shifts. @@ -391,9 +391,9 @@ convention for other `kicad-cli`-calling code. ## Current Stats -- **21 toolsets, 225 tools** + 7 meta-tools (4 routing + 2 observability + 1 runtime diagnostic — see `tool-directory.md`) +- **21 toolsets, 226 tools** + 7 meta-tools (4 routing + 2 observability + 1 runtime diagnostic — see `tool-directory.md`) - Baseline `tools/list`: 21 tools / ~2K tokens (starter kit + meta-tools) -- Full-catalog `tools/list` (all loaded): 232 tools (225 registered + 7 meta) / ~25K tokens +- Full-catalog `tools/list` (all loaded): 233 tools (226 registered + 7 meta) / ~25K tokens - **0 IPC stubs** (all protobuf methods implemented) - **0 unimplemented tools** - **Specctra DSN/SES are PCB-editor operations**, not `kicad-cli` commands. diff --git a/README.md b/README.md index 21dfa178..3879fe2c 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Rust binary — that lets Claude and other AI assistants design schematics and PCBs through the [Model Context Protocol](https://modelcontextprotocol.io) (MCP). -**225 tools across 21 on-demand toolsets.** Schematic capture, PCB layout and +**226 tools across 21 on-demand toolsets.** Schematic capture, PCB layout and routing, ERC/DRC, design-review audits, JLCPCB part search, reference circuits, and a full manufacturing export pipeline — with bundled skills and agents that teach Claude KiCAD conventions out of the box. @@ -70,7 +70,7 @@ through its own S-expression engine with atomic writes (write, fsync, rename), U preservation, and round-trip tests — no third-party schematic library with known gaps, no text-manipulation workarounds. -**Context economy is a feature.** Exposing all 225 tools to an LLM costs roughly 23K +**Context economy is a feature.** Exposing all 226 tools to an LLM costs roughly 23K tokens of context on every listing. Konnect's router loads a starter kit (~2K tokens) and lets the model pull in toolsets on demand — plus built-in observability (`get_recent_calls`, `server_stats`, JSONL call logs) so the model can diagnose its diff --git a/crates/konnect-core/src/router/registry.rs b/crates/konnect-core/src/router/registry.rs index c227def1..bce96262 100644 --- a/crates/konnect-core/src/router/registry.rs +++ b/crates/konnect-core/src/router/registry.rs @@ -28,7 +28,7 @@ pub static ALL_TOOLSETS: &[ToolsetMeta] = &[ name: "editor_navigation", description: "Observe and semantically navigate exact KiCad editor, document, sheet, selection, and cross-probe context", category: "project", - tool_count: 4, + tool_count: 5, }, ToolsetMeta { name: "sch_components", diff --git a/crates/konnect-core/src/tools/editor_navigation.rs b/crates/konnect-core/src/tools/editor_navigation.rs index 60c6d759..8befdc67 100644 --- a/crates/konnect-core/src/tools/editor_navigation.rs +++ b/crates/konnect-core/src/tools/editor_navigation.rs @@ -9,8 +9,9 @@ use crate::mcp::{error::ToolErrorKind, protocol::CallToolResult}; use crate::tool; use crate::tools::{invalid_arg, opt_str, require_array, require_str, ToolContext, ToolDef}; use konnect_ipc::{ - IpcEditorDocument, IpcEditorKind, IpcProjectIdentity, IpcSelectionMutation, - IpcSelectionMutationErrorKind, IpcSelectionObservationErrorKind, IpcSheetInstancePath, + IpcCapabilityAvailability, IpcEditorDocument, IpcEditorKind, IpcProjectIdentity, + IpcSelectionMutation, IpcSelectionMutationErrorKind, IpcSelectionObservationErrorKind, + IpcSheetInstancePath, }; use serde_json::json; use std::path::PathBuf; @@ -90,6 +91,25 @@ pub fn tools() -> Vec { }), |args, ctx| async move { handle_mutate_editor_selection(args, ctx).await } ), + tool!( + "activate_editor_context", + "Semantically request exact document/sheet activation, object reveal/centering, or view fitting. The operation is capability-gated and returns a typed unsupported result when the running KiCad protocol cannot perform and read back the requested behavior; no raw action is exposed.", + json!({ + "type": "object", + "properties": { + "operation": { "type": "string", "enum": ["activate", "reveal", "center", "fit"] }, + "editor": { "type": "string", "enum": ["schematic", "pcb"] }, + "project_name": { "type": "string" }, + "project_path": { "type": "string" }, + "document_path": { "type": "string", "description": "Exact saved .kicad_sch or .kicad_pcb document" }, + "sheet_instance_path": { "type": "array", "items": { "type": "string" } }, + "sheet_path_human_readable": { "type": "string" }, + "object_kiid": { "type": "string", "description": "Required only for reveal or center" } + }, + "required": ["operation", "editor", "project_name", "project_path", "document_path"] + }), + |args, ctx| async move { handle_activate_editor_context(args, ctx).await } + ), ] } @@ -687,6 +707,211 @@ fn selection_mutation_error_result(error: anyhow::Error) -> CallToolResult { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum EditorActivationOperation { + Activate, + Reveal, + Center, + Fit, +} + +#[derive(Debug)] +struct EditorActivationRequest { + operation: EditorActivationOperation, + live_document: IpcEditorDocument, + structural_target: Option, +} + +async fn handle_activate_editor_context( + args: &serde_json::Value, + ctx: &ToolContext, +) -> anyhow::Result { + let request = match parse_editor_activation_request(args) { + Ok(request) => request, + Err(result) => return Ok(result), + }; + let address = ctx.config.ipc_address.clone(); + if address.is_empty() { + return Ok(editor_unavailable("no KiCad IPC endpoint is configured")); + } + let result = tokio::task::spawn_blocking(move || -> anyhow::Result<_> { + let resolved_target = request + .structural_target + .as_ref() + .map(resolve_navigation_target) + .transpose()?; + let client = konnect_ipc::KiCadIpcClient::new(address); + let live_document = client.observe_exact_open_document(&request.live_document)?; + let state = client.observe_editor_state()?; + Ok((request.operation, live_document, resolved_target, state)) + }) + .await?; + let (operation, live_document, _resolved_target, state) = match result { + Ok(result) => result, + Err(error) => return Ok(selection_mutation_error_result(error)), + }; + let editor = state + .editors + .iter() + .find(|observed| observed.editor == live_document.editor); + let capability_name = activation_capability_name(operation, live_document.editor); + let capability = editor.map(|observed| match operation { + EditorActivationOperation::Activate => match live_document.editor { + IpcEditorKind::Schematic => &observed.capabilities.activate_sheet, + IpcEditorKind::Pcb => &observed.capabilities.activate_document, + }, + EditorActivationOperation::Reveal => &observed.capabilities.reveal_object, + EditorActivationOperation::Center => &observed.capabilities.center_object, + EditorActivationOperation::Fit => &observed.capabilities.fit_view, + }); + let reason = capability + .and_then(|capability| capability.reason.as_deref()) + .unwrap_or("the bundled semantic adapter cannot perform and read back this operation"); + if capability.is_none() + || capability.is_some_and(|capability| { + capability.availability != IpcCapabilityAvailability::Available + }) + { + return Ok(CallToolResult::error_kind( + ToolErrorKind::UnsupportedCapability { + capability: capability_name.to_string(), + kicad_version: Some(state.kicad_version.full_version), + }, + format!( + "KiCad cannot safely perform {capability_name} for the exact requested context: {reason}." + ), + )); + } + + // A future protocol may advertise support before Konnect has a semantic + // adapter with active-context readback. That still is not permission to + // expose or send an unstable action. + Ok(CallToolResult::error_kind( + ToolErrorKind::UnsupportedCapability { + capability: capability_name.to_string(), + kicad_version: Some(state.kicad_version.full_version), + }, + format!( + "KiCad advertises {capability_name}, but Konnect has no version-gated semantic adapter with active-context readback." + ), + )) +} + +fn activation_capability_name( + operation: EditorActivationOperation, + editor: IpcEditorKind, +) -> &'static str { + match operation { + EditorActivationOperation::Activate if editor == IpcEditorKind::Schematic => { + "activate_sheet" + } + EditorActivationOperation::Activate => "activate_document", + EditorActivationOperation::Reveal => "reveal_object", + EditorActivationOperation::Center => "center_object", + EditorActivationOperation::Fit => "fit_view", + } +} + +fn parse_editor_activation_request( + args: &serde_json::Value, +) -> Result { + let operation = match require_str(args, "operation")? { + "activate" => EditorActivationOperation::Activate, + "reveal" => EditorActivationOperation::Reveal, + "center" => EditorActivationOperation::Center, + "fit" => EditorActivationOperation::Fit, + _ => { + return Err(invalid_arg( + "operation", + "expected 'activate', 'reveal', 'center', or 'fit'", + )) + } + }; + let editor = match require_str(args, "editor")? { + "schematic" => IpcEditorKind::Schematic, + "pcb" => IpcEditorKind::Pcb, + _ => return Err(invalid_arg("editor", "expected 'schematic' or 'pcb'")), + }; + let project_name = require_str(args, "project_name")?; + let project_path = require_str(args, "project_path")?; + let document_path = require_str(args, "document_path")?; + if project_name.is_empty() || project_path.is_empty() || document_path.is_empty() { + return Err(invalid_arg( + "project_name", + "project and document identity strings must not be empty", + )); + } + let project = IpcProjectIdentity { + name: project_name.to_string(), + path: project_path.to_string(), + }; + let sheet_instance_path = match editor { + IpcEditorKind::Pcb => { + if !args["sheet_instance_path"].is_null() + || !args["sheet_path_human_readable"].is_null() + { + return Err(invalid_arg( + "sheet_instance_path", + "PCB targets cannot carry schematic sheet identity", + )); + } + None + } + IpcEditorKind::Schematic => { + let ids = require_array(args, "sheet_instance_path")? + .iter() + .map(|value| value.as_str().map(str::to_string)) + .collect::>>() + .ok_or_else(|| { + invalid_arg("sheet_instance_path", "every entry must be a string") + })?; + if ids.is_empty() || ids.iter().any(String::is_empty) { + return Err(invalid_arg( + "sheet_instance_path", + "must contain non-empty root-to-leaf KIIIDs", + )); + } + Some(IpcSheetInstancePath { + kiids: ids, + human_readable: opt_str(args, "sheet_path_human_readable") + .unwrap_or("") + .to_string(), + }) + } + }; + let object_kiid = opt_str(args, "object_kiid").map(str::to_string); + let needs_object = matches!( + operation, + EditorActivationOperation::Reveal | EditorActivationOperation::Center + ); + if object_kiid.as_deref().is_some_and(str::is_empty) || needs_object != object_kiid.is_some() { + return Err(invalid_arg( + "object_kiid", + "reveal/center require one non-empty KIID; activate/fit do not accept one", + )); + } + let saved_document = PathBuf::from(document_path); + let structural_target = object_kiid.map(|kiid| NavigationTargetRequest { + editor, + project: project.clone(), + document_path: saved_document.clone(), + sheet_instance_path: sheet_instance_path.clone(), + object_kiid: Some(kiid), + human_reference: None, + }); + Ok(EditorActivationRequest { + operation, + live_document: IpcEditorDocument { + editor, + project: Some(project), + document_path: (editor == IpcEditorKind::Pcb) + .then(|| saved_document.display().to_string()), + sheet_instance_path, + }, + structural_target, + }) +} + #[cfg(test)] mod tests { use super::*; @@ -698,7 +923,7 @@ mod tests { use konnect_ipc::gen::kiapi; use nng::options::Options; use prost::Message; - use std::sync::Arc; + use std::sync::{Arc, Mutex}; use std::time::Duration; fn context(ipc_address: String) -> ToolContext { @@ -956,10 +1181,85 @@ mod tests { url } + fn spawn_activation_capability_mock( + project_path: String, + commands: Arc>>, + ) -> String { + static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let url = format!( + "inproc://activation-capability-core-{}", + NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + ); + let socket = nng::Socket::new(nng::Protocol::Rep0).expect("mock socket"); + socket.listen(&url).expect("listen"); + std::thread::spawn(move || { + for _ in 0..4 { + let message = socket.recv().expect("request"); + let request = + kiapi::common::ApiRequest::decode(message.as_slice()).expect("decode request"); + let command = request.message.expect("command"); + commands.lock().unwrap().push(command.type_url.clone()); + let mut response = kiapi::common::ApiResponse { + status: Some(kiapi::common::ApiResponseStatus { + status: kiapi::common::ApiStatusCode::AsOk as i32, + error_message: String::new(), + }), + header: None, + message: None, + }; + if command.type_url.ends_with("GetVersion") { + response.message = Some(builders::pack_any( + &kiapi::common::commands::GetVersionResponse { + version: Some(kiapi::common::types::KiCadVersion { + major: 10, + minor: 0, + patch: 5, + full_version: "10.0.5".to_string(), + }), + }, + "kiapi.common.commands.GetVersionResponse", + )); + } else { + let query = + kiapi::common::commands::GetOpenDocuments::decode(command.value.as_slice()) + .expect("open documents query"); + if query.r#type == kiapi::common::types::DocumentType::DoctypeSchematic as i32 { + response.status = Some(kiapi::common::ApiResponseStatus { + status: kiapi::common::ApiStatusCode::AsUnhandled as i32, + error_message: "schematic endpoint unavailable".to_string(), + }); + } else { + response.message = Some(builders::pack_any( + &kiapi::common::commands::GetOpenDocumentsResponse { + documents: vec![kiapi::common::types::DocumentSpecifier { + r#type: kiapi::common::types::DocumentType::DoctypePcb as i32, + identifier: Some( + kiapi::common::types::document_specifier::Identifier::BoardFilename( + "layout.kicad_pcb".to_string(), + ), + ), + project: Some(kiapi::common::types::ProjectSpecifier { + name: "nav".to_string(), + path: project_path.clone(), + }), + }], + }, + "kiapi.common.commands.GetOpenDocumentsResponse", + )); + } + } + socket + .send(nng::Message::from(response.encode_to_vec().as_slice())) + .expect("response"); + } + }); + url + } + #[test] fn public_tool_is_read_only_and_takes_no_required_arguments() { let definitions = tools(); - assert_eq!(definitions.len(), 4); + assert_eq!(definitions.len(), 5); let state = definitions .iter() .find(|tool| tool.name == "get_editor_state") @@ -996,6 +1296,20 @@ mod tests { "object_kiids" ]) ); + let activation = definitions + .iter() + .find(|tool| tool.name == "activate_editor_context") + .expect("activation tool"); + assert_eq!( + activation.input_schema["required"], + json!([ + "operation", + "editor", + "project_name", + "project_path", + "document_path" + ]) + ); } #[tokio::test] @@ -1275,4 +1589,92 @@ mod tests { Some("unsupported_capability") ); } + + #[test] + fn activation_parser_requires_object_identity_only_for_object_operations() { + let base = json!({ + "operation": "activate", + "editor": "pcb", + "project_name": "navigation", + "project_path": r"C:\design", + "document_path": r"C:\design\navigation.kicad_pcb" + }); + assert!(parse_editor_activation_request(&base).is_ok()); + let mut invalid = base.clone(); + invalid["operation"] = json!("reveal"); + let error = parse_editor_activation_request(&invalid).expect_err("reveal needs KIID"); + assert_eq!( + extract_error_kind(&error).as_deref(), + Some("invalid_argument") + ); + assert_eq!( + activation_capability_name( + EditorActivationOperation::Activate, + IpcEditorKind::Schematic + ), + "activate_sheet" + ); + } + + #[tokio::test] + async fn exact_activation_is_typed_unsupported_and_sends_no_action() { + let temp = tempfile::tempdir().unwrap(); + let board = temp.path().join("layout.kicad_pcb"); + std::fs::write(&board, "(kicad_pcb)").unwrap(); + let project_path = temp.path().display().to_string(); + let commands = Arc::new(Mutex::new(Vec::new())); + let result = handle_activate_editor_context( + &json!({ + "operation": "activate", + "editor": "pcb", + "project_name": "nav", + "project_path": project_path.clone(), + "document_path": board.display().to_string() + }), + &context(spawn_activation_capability_mock( + project_path, + commands.clone(), + )), + ) + .await + .expect("handler result"); + assert_eq!( + extract_error_kind(&result).as_deref(), + Some("unsupported_capability") + ); + let ToolContent::Text { text } = &result.content[0] else { + panic!("expected text result"); + }; + let body: serde_json::Value = serde_json::from_str(text).unwrap(); + assert_eq!(body["error"]["capability"], "activate_document"); + assert_eq!(body["error"]["kicad_version"], "10.0.5"); + let commands = commands.lock().unwrap(); + assert_eq!(commands.len(), 4); + assert!(commands.iter().all(|command| { + command.ends_with("GetVersion") || command.ends_with("GetOpenDocuments") + })); + assert!(!commands.iter().any(|command| command.contains("RunAction"))); + } + + #[tokio::test] + async fn stale_reveal_object_is_refused_before_any_live_action() { + let temp = tempfile::tempdir().unwrap(); + std::fs::write(temp.path().join("nav.kicad_pro"), "{}").unwrap(); + let board = temp.path().join("layout.kicad_pcb"); + std::fs::write(&board, "(kicad_pcb)").unwrap(); + let result = handle_activate_editor_context( + &json!({ + "operation": "reveal", + "editor": "pcb", + "project_name": "nav", + "project_path": temp.path().display().to_string(), + "document_path": board.display().to_string(), + "object_kiid": "gone" + }), + &context("inproc://must-not-be-contacted".to_string()), + ) + .await + .expect("handler result"); + assert_eq!(extract_error_kind(&result).as_deref(), Some("stale_target")); + } } diff --git a/crates/konnect-ipc/src/client.rs b/crates/konnect-ipc/src/client.rs index 8671d823..a1b7456e 100644 --- a/crates/konnect-ipc/src/client.rs +++ b/crates/konnect-ipc/src/client.rs @@ -3502,7 +3502,9 @@ fn editor_capabilities( mutate_selection: selection, activate_document: no_activation.clone(), activate_sheet: no_activation, - reveal_object: no_reveal, + reveal_object: no_reveal.clone(), + center_object: no_reveal.clone(), + fit_view: no_reveal, cross_probe: no_cross_probe, } } diff --git a/crates/konnect-ipc/src/types.rs b/crates/konnect-ipc/src/types.rs index e436bf5e..e10de0a7 100644 --- a/crates/konnect-ipc/src/types.rs +++ b/crates/konnect-ipc/src/types.rs @@ -46,6 +46,8 @@ pub struct IpcEditorCapabilities { pub activate_document: IpcCapability, pub activate_sheet: IpcCapability, pub reveal_object: IpcCapability, + pub center_object: IpcCapability, + pub fit_view: IpcCapability, pub cross_probe: IpcCapability, } diff --git a/docs/KICAD_INTEGRATION.md b/docs/KICAD_INTEGRATION.md index 5378ad21..3012f4bc 100644 --- a/docs/KICAD_INTEGRATION.md +++ b/docs/KICAD_INTEGRATION.md @@ -79,6 +79,15 @@ entire observed set with the expected before/after transition, and returns a structured `readback_mismatch` if KiCad did not make precisely that change. Duplicate or empty KIID requests are rejected before IPC. +Exact activation and viewport requests are exposed as semantic operations, not +action strings. Konnect first proves the requested live project/document/sheet +and, for reveal or center, resolves the saved object KIID. It then consults the +running-version capability record. The bundled KiCad 10 protocol has no stable +typed document/sheet activation, reveal, center, or fit command and no active- +context readback, so these requests return `unsupported_capability` and send no +`RunAction`. A future adapter must be version-gated and add result-derived +active-context readback before any of these operations can report success. + ## Schematic-To-Board Sync `update_pcb_from_schematic` in `tools/pcb_sync.rs` is live-IPC-only. It uses diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 38fd26f0..6e5522ca 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -265,7 +265,7 @@ callable tools, the fix is to make the *first* listing complete: ``` in `konnect.toml` in the working directory, or a `settings.json` beside the binary. Every toolset is then loaded at -startup, so `tools/list` carries all 232 tools from the first call. +startup, so `tools/list` carries all 233 tools from the first call. It is off by default because it costs what the router exists to save: roughly 25K tokens per listing instead of ~2K. Turn it on only if your client needs it. diff --git a/packaging/metadata.json b/packaging/metadata.json index ec983ee3..44d76ef9 100644 --- a/packaging/metadata.json +++ b/packaging/metadata.json @@ -1,7 +1,7 @@ { "$schema": "https://go.kicad.org/pcm/schemas/v1", "name": "Konnect", - "description": "AI-assisted PCB design via the Model Context Protocol. Enables Claude and other AI assistants to design schematics and PCBs with 225 tools organized into on-demand toolsets.", + "description": "AI-assisted PCB design via the Model Context Protocol. Enables Claude and other AI assistants to design schematics and PCBs with 226 tools organized into on-demand toolsets.", "description_full": "Konnect exposes a complete set of KiCAD design tools to AI assistants via the Model Context Protocol (MCP). It supports schematic editing, PCB layout, local Freerouting MCP routing, library management, JLCPCB part search, ERC/DRC, design review audits, and full export pipelines. Tools are organized into 21 toolsets loaded on demand so the AI only sees relevant tools at once.", "identifier": "com.github.mixelpixx.konnect", "type": "plugin", diff --git a/plugin/plugin.json b/plugin/plugin.json index 2ccedd63..df2cedc1 100644 --- a/plugin/plugin.json +++ b/plugin/plugin.json @@ -1,7 +1,7 @@ { "identifier": "com.github.mixelpixx.konnect", "name": "Konnect", - "description": "AI-assisted PCB design via the Model Context Protocol. 225 tools for schematic editing, PCB layout, routing, design review, and manufacturing export.", + "description": "AI-assisted PCB design via the Model Context Protocol. 226 tools for schematic editing, PCB layout, routing, design review, and manufacturing export.", "runtime": { "type": "exec" }, diff --git a/tool-directory.md b/tool-directory.md index 90eae97c..0a9a1ec4 100644 --- a/tool-directory.md +++ b/tool-directory.md @@ -13,7 +13,7 @@ Compatibility notes for removed or narrowed arguments are recorded in ## Overview - **21 toolsets** organized into 10 categories -- **225 registered tools** + **7 always-visible meta-tools** = **232 total** +- **226 registered tools** + **7 always-visible meta-tools** = **233 total** - **Discovery pattern**: the server pre-loads only the **starter kit** (`project`, `config`) so baseline `tools/list` costs ~2K tokens instead of ~23K. The LLM reads `list_toolboxes` → calls `load_toolset(name)` to expose additional tools on demand; `unload_toolset(name)` prunes them. `tools/list_changed` is notified on every mutation. If the LLM calls a tool whose toolset isn't loaded, the error names the owning toolset so recovery is a single `load_toolset` hop. `load_toolset` also accepts an array of names to load several toolsets with a single `tools/list` refresh. - **Observability**: every `tools/call` is recorded — ring buffer of the last 100 calls + per-tool counters + JSONL at `/logs/calls.jsonl`. The LLM self-diagnoses via `get_recent_calls` and `server_stats`. @@ -61,7 +61,7 @@ Seven tools, grouped into *discovery/routing*, *observability*, and *runtime dia | `snapshot_project` | Export the schematic and PCB to PDF as a timestamped snapshot/checkpoint. Useful before major edits. | | `open_schematic_viewer` | Launch the live schematic viewer (SVG with auto-refresh on file change). Use after placing components so the user can see changes in real time. | -### `editor_navigation` · 4 tools +### `editor_navigation` · 5 tools **Purpose:** Observe and semantically navigate exact KiCad editor, document, sheet, selection, and cross-probe context. **Source:** [`crates/konnect-core/src/tools/editor_navigation.rs`](crates/konnect-core/src/tools/editor_navigation.rs) @@ -71,6 +71,7 @@ Seven tools, grouped into *discovery/routing*, *observability*, and *runtime dia | `get_editor_selection` | Read the selection from one exact editor, project, document, and hierarchical sheet instance with stable KIID/UUID identities and document-readback freshness checks. | | `resolve_navigation_target` | Resolve an exact open project/document/sheet object by stable KIID, or by a human reference only when saved KiCad structure yields one unambiguous candidate. | | `mutate_editor_selection` | Clear, add to, or remove from one exact editor selection after saved-object validation; report success only when a fresh typed selection readback proves the complete requested set transition. | +| `activate_editor_context` | Capability-gate exact document/sheet activation, object reveal/centering, and view fitting; return typed unsupported behavior when no stable command plus active-context readback exists, without sending raw actions. | ---