feat: support arbitrary custom properties on schematic components

Promotes BOM / sourcing fields (MPN, Manufacturer, DigiKey_PN, LCSC,
JLCPCB_PN, Voltage, Tolerance, Dielectric, ...) to first-class citizens
on placed schematic symbols.

New MCP tools:
- set_schematic_component_property: add or update one custom property
  on a component (convenience wrapper around edit_schematic_component).
- remove_schematic_component_property: delete one custom property.
  The four built-in fields (Reference, Value, Footprint, Datasheet) are
  protected and rejected.

edit_schematic_component enhancements:
- New `properties` parameter: map of property name to either a string
  value or a full spec object { value, x?, y?, angle?, hide?, fontSize? }.
  Adds the property when missing, otherwise updates the existing field
  (and optionally its label position / visibility). Lets a single tool
  call attach an entire BOM payload to a component.
- New `removeProperties` parameter: list of custom property names to
  delete in the same call.
- Property values are now backslash-escaped so descriptions containing
  a double-quote or a backslash no longer corrupt the .kicad_sch file.
- New properties default to (hide yes) so they appear in BOM exports
  without cluttering the schematic canvas.

get_schematic_component description clarified to highlight that it
already returns every field on the symbol, including custom ones.

New MCP prompt component_sourcing_properties guides agents through the
conventional property names recognised by downstream BOM tooling and
the recommended call sequence.

Implementation (python/kicad_interface.py):
- _PROTECTED_PROPERTY_FIELDS frozenset
- _escape_sexpr_string / _find_matching_paren static helpers
- _set_property_in_block / _set_hide_on_property /
  _remove_property_from_block surgical text-level edits that preserve
  formatting and the property's UUID
- _handle_edit_schematic_component rewritten to orchestrate
  add/update/remove and return a per-property summary
- New handlers _handle_set_schematic_component_property and
  _handle_remove_schematic_component_property registered in the
  command dispatch table

Tests (tests/test_schematic_component_properties.py):
32 tests covering escape helper, paren matcher, add/update/remove
(single + batched), full spec dicts, default position, default
(hide yes), special-character escaping, UUID preservation, protected
built-in field rejection, no-op removal, both new convenience tools,
and input validation. All 590 tests in the project still pass.

Docs: README, SCHEMATIC_TOOLS_REFERENCE, TOOL_INVENTORY, CHANGELOG.
This commit is contained in:
William Viana
2026-04-20 17:36:38 -07:00
parent 5b7434fdef
commit 4d7843c03a
8 changed files with 1471 additions and 64 deletions

View File

@@ -13,7 +13,7 @@ import os
import sys
import traceback
from pathlib import Path
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Optional, Tuple
from resources.resource_definitions import RESOURCE_DEFINITIONS, handle_resource_read
@@ -373,6 +373,8 @@ class KiCADInterface:
"add_schematic_component": self._handle_add_schematic_component,
"delete_schematic_component": self._handle_delete_schematic_component,
"edit_schematic_component": self._handle_edit_schematic_component,
"set_schematic_component_property": self._handle_set_schematic_component_property,
"remove_schematic_component_property": self._handle_remove_schematic_component_property,
"get_schematic_component": self._handle_get_schematic_component,
"add_schematic_wire": self._handle_add_schematic_wire,
"add_schematic_net_label": self._handle_add_schematic_net_label,
@@ -856,9 +858,215 @@ class KiCADInterface:
logger.error(traceback.format_exc())
return {"success": False, "message": str(e)}
# Built-in property names that have dedicated parameters and cannot be removed
# via the generic removeProperties path. They are also written by KiCad on every
# save, so deleting them produces an invalid schematic.
_PROTECTED_PROPERTY_FIELDS = frozenset({"Reference", "Value", "Footprint", "Datasheet"})
@staticmethod
def _escape_sexpr_string(value: str) -> str:
"""Escape a string for safe insertion into an S-expression double-quoted token."""
return value.replace("\\", "\\\\").replace('"', '\\"')
@staticmethod
def _find_matching_paren(s: str, start: int) -> int:
"""Return the index of the closing paren matching the opening paren at `start`.
Returns -1 if no match is found. Does not understand string literals — that's
fine for KiCAD .kicad_sch files because property values cannot contain a
bare `(` or `)` character (they would be backslash-escaped).
"""
depth = 0
i = start
while i < len(s):
if s[i] == "(":
depth += 1
elif s[i] == ")":
depth -= 1
if depth == 0:
return i
i += 1
return -1
def _set_property_in_block(
self,
block: str,
name: str,
spec: Dict[str, Any],
default_position: Tuple[float, float],
) -> Tuple[str, str]:
"""Add or update a property within a placed-symbol block.
Args:
block: The full text of the (symbol ...) block.
name: Property name (e.g. "MPN", "Manufacturer").
spec: Dict that may contain keys: value, x, y, angle, hide, fontSize.
default_position: (x, y) of the parent symbol — used as the default
location for newly-created properties so the field is anchored
near the component, not at (0, 0).
Returns:
Tuple of (new_block_text, action_taken) where action is "added" or "updated".
"""
import re
new_value = spec.get("value")
new_x = spec.get("x")
new_y = spec.get("y")
new_angle = spec.get("angle")
new_hide = spec.get("hide")
font_size = spec.get("fontSize", 1.27)
existing_match = re.search(
r'\(property\s+"' + re.escape(name) + r'"\s+"',
block,
)
if existing_match:
# Property exists — patch value / position / hide in place
if new_value is not None:
escaped = self._escape_sexpr_string(str(new_value))
block = re.sub(
r'(\(property\s+"' + re.escape(name) + r'"\s+)"[^"]*"',
rf'\1"{escaped}"',
block,
count=1,
)
if new_x is not None or new_y is not None or new_angle is not None:
pos_match = re.search(
r'(\(property\s+"'
+ re.escape(name)
+ r'"\s+"[^"]*"\s+\(at\s+)([\d\.\-]+)\s+([\d\.\-]+)\s+([\d\.\-]+)(\s*\))',
block,
)
if pos_match:
cx = new_x if new_x is not None else float(pos_match.group(2))
cy = new_y if new_y is not None else float(pos_match.group(3))
ca = new_angle if new_angle is not None else float(pos_match.group(4))
block = (
block[: pos_match.start()]
+ pos_match.group(1)
+ f"{cx} {cy} {ca}"
+ pos_match.group(5)
+ block[pos_match.end() :]
)
if new_hide is not None:
block = self._set_hide_on_property(block, name, bool(new_hide))
return block, "updated"
# Property does not exist — append a new one after the last existing property
if new_value is None:
# Adding a brand-new property requires at least a value
raise ValueError(
f"Property '{name}' does not exist on this component yet — supply a value to create it"
)
cx = new_x if new_x is not None else default_position[0]
cy = new_y if new_y is not None else default_position[1]
ca = new_angle if new_angle is not None else 0
# New properties default to hidden (BOM/sourcing data normally has no
# visible footprint on the schematic canvas).
hide_str = "(hide yes)" if (new_hide is None or new_hide) else "(hide no)"
escaped = self._escape_sexpr_string(str(new_value))
escaped_name = self._escape_sexpr_string(str(name))
new_prop = (
f' (property "{escaped_name}" "{escaped}" (at {cx} {cy} {ca})\n'
f" (effects (font (size {font_size} {font_size})) {hide_str})\n"
f" )"
)
# Find the last existing property block and insert immediately after it.
last_prop_end = -1
for m in re.finditer(r'\(property\s+"', block):
end = self._find_matching_paren(block, m.start())
if end > last_prop_end:
last_prop_end = end
if last_prop_end < 0:
# No properties at all — insert just before the closing paren of the symbol
block_close = block.rfind(")")
if block_close < 0:
raise ValueError("Malformed symbol block: no closing paren")
block = block[:block_close] + "\n" + new_prop + "\n " + block[block_close:]
else:
block = block[: last_prop_end + 1] + "\n" + new_prop + block[last_prop_end + 1 :]
return block, "added"
def _set_hide_on_property(self, block: str, name: str, hide: bool) -> str:
"""Set the (hide yes|no) flag on a named property's effects clause.
Handles three pre-existing forms:
(effects (font (size 1.27 1.27))) — no hide flag
(effects (font (size 1.27 1.27)) hide) — legacy bare token
(effects (font (size 1.27 1.27)) (hide yes|no)) — KiCad 9 form
"""
import re
prop_match = re.search(
r'\(property\s+"' + re.escape(name) + r'"',
block,
)
if not prop_match:
return block
prop_start = prop_match.start()
prop_end = self._find_matching_paren(block, prop_start)
if prop_end < 0:
return block
# Locate the (effects ...) clause inside the property
prop_segment = block[prop_start : prop_end + 1]
eff_match = re.search(r"\(effects\b", prop_segment)
if not eff_match:
return block
eff_start = prop_start + eff_match.start()
eff_end = self._find_matching_paren(block, eff_start)
if eff_end < 0:
return block
eff_inner = block[eff_start + 1 : eff_end] # 'effects (font ...) ...'
eff_inner = re.sub(r"\s*\(hide\s+(yes|no)\)", "", eff_inner)
eff_inner = re.sub(r"\s+hide\b(?!\s+(yes|no))", "", eff_inner)
eff_inner = eff_inner.rstrip() + f' (hide {"yes" if hide else "no"})'
new_effects = "(" + eff_inner + ")"
return block[:eff_start] + new_effects + block[eff_end + 1 :]
def _remove_property_from_block(self, block: str, name: str) -> Tuple[str, bool]:
"""Remove a property from the symbol block. Returns (new_block, removed_bool)."""
import re
m = re.search(r'\(property\s+"' + re.escape(name) + r'"\s+"', block)
if not m:
return block, False
start = m.start()
end = self._find_matching_paren(block, start)
if end < 0:
return block, False
# Trim surrounding whitespace (leading newline + indent) so the resulting
# file does not develop blank lines after every removal.
trim_start = start
while trim_start > 0 and block[trim_start - 1] in (" ", "\t"):
trim_start -= 1
if trim_start > 0 and block[trim_start - 1] == "\n":
trim_start -= 1
return block[:trim_start] + block[end + 1 :], True
def _handle_edit_schematic_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Update properties of a placed symbol in a schematic (footprint, value, reference).
Uses text-based in-place editing preserves position, UUID and all other fields.
"""Update properties of a placed symbol in a schematic.
Supports updating the standard fields (footprint / value / reference rename),
repositioning field labels, and managing **arbitrary custom properties**
(MPN, Manufacturer, Distributor part numbers, Voltage, Dielectric, Tolerance,
LCSC, etc.) used by BOM/CPL exporters and JLCPCB / Digi-Key sourcing.
Uses text-based in-place editing — preserves position, UUID, and all
unrelated fields.
"""
logger.info("Editing schematic component")
try:
@@ -870,9 +1078,12 @@ class KiCADInterface:
new_footprint = params.get("footprint")
new_value = params.get("value")
new_reference = params.get("newReference")
field_positions = params.get(
"fieldPositions"
) # dict: {"Reference": {"x": 1, "y": 2, "angle": 0}}
# dict: {"Reference": {"x": 1, "y": 2, "angle": 0}}
field_positions = params.get("fieldPositions")
# dict: {"MPN": "RC0603FR-0710KL"} OR {"MPN": {"value": "...", "hide": true}}
properties = params.get("properties")
# list[str]: ["OldField"] — protected built-ins are rejected
remove_properties = params.get("removeProperties")
if not schematic_path:
return {"success": False, "message": "schematicPath is required"}
@@ -884,13 +1095,30 @@ class KiCADInterface:
new_value is not None,
new_reference is not None,
field_positions is not None,
properties is not None,
remove_properties is not None,
]
):
return {
"success": False,
"message": "At least one of footprint, value, newReference, or fieldPositions must be provided",
"message": (
"At least one of footprint, value, newReference, fieldPositions, "
"properties, or removeProperties must be provided"
),
}
# Reject removal attempts targeting protected built-in fields up-front
if remove_properties:
blocked = [n for n in remove_properties if n in self._PROTECTED_PROPERTY_FIELDS]
if blocked:
return {
"success": False,
"message": (
f"Cannot remove built-in field(s) {blocked}: use the dedicated "
"value/footprint/newReference parameters or set the value to ''"
),
}
sch_file = Path(schematic_path)
if not sch_file.exists():
return {
@@ -901,23 +1129,11 @@ class KiCADInterface:
with open(sch_file, "r", encoding="utf-8") as f:
content = f.read()
def find_matching_paren(s: str, start: int) -> int:
"""Find the position of the closing paren matching the opening paren at start."""
depth = 0
i = start
while i < len(s):
if s[i] == "(":
depth += 1
elif s[i] == ")":
depth -= 1
if depth == 0:
return i
i += 1
return -1
# Skip lib_symbols section
lib_sym_pos = content.find("(lib_symbols")
lib_sym_end = find_matching_paren(content, lib_sym_pos) if lib_sym_pos >= 0 else -1
lib_sym_end = (
self._find_matching_paren(content, lib_sym_pos) if lib_sym_pos >= 0 else -1
)
# Find placed symbol blocks that match the reference
# Search for (symbol (lib_id "...") ... (property "Reference" "<ref>" ...) ...)
@@ -933,7 +1149,7 @@ class KiCADInterface:
if lib_sym_pos >= 0 and lib_sym_pos <= pos <= lib_sym_end:
search_start = lib_sym_end + 1
continue
end = find_matching_paren(content, pos)
end = self._find_matching_paren(content, pos)
if end < 0:
search_start = pos + 1
continue
@@ -954,20 +1170,36 @@ class KiCADInterface:
# Apply property replacements within the found block
block_text = content[block_start : block_end + 1]
# Determine the parent symbol position so that newly-added properties
# default to a sensible location (anchored near the component).
comp_at = re.search(
r'\(symbol\s+\(lib_id\s+"[^"]*"\s*\)\s+\(at\s+([\d\.\-]+)\s+([\d\.\-]+)',
block_text,
)
comp_origin: Tuple[float, float] = (
(float(comp_at.group(1)), float(comp_at.group(2))) if comp_at else (0.0, 0.0)
)
if new_footprint is not None:
escaped_fp = self._escape_sexpr_string(str(new_footprint))
block_text = re.sub(
r'(\(property\s+"Footprint"\s+)"[^"]*"',
rf'\1"{new_footprint}"',
rf'\1"{escaped_fp}"',
block_text,
)
if new_value is not None:
escaped_v = self._escape_sexpr_string(str(new_value))
block_text = re.sub(
r'(\(property\s+"Value"\s+)"[^"]*"', rf'\1"{new_value}"', block_text
r'(\(property\s+"Value"\s+)"[^"]*"',
rf'\1"{escaped_v}"',
block_text,
)
if new_reference is not None:
escaped_r = self._escape_sexpr_string(str(new_reference))
block_text = re.sub(
r'(\(property\s+"Reference"\s+)"[^"]*"',
rf'\1"{new_reference}"',
rf'\1"{escaped_r}"',
block_text,
)
if field_positions is not None:
@@ -983,12 +1215,50 @@ class KiCADInterface:
block_text,
)
properties_added: Dict[str, Any] = {}
properties_updated: Dict[str, Any] = {}
if properties:
if not isinstance(properties, dict):
return {
"success": False,
"message": "properties must be a dict mapping property name -> value or spec",
}
for name, spec in properties.items():
if not isinstance(name, str) or not name:
return {
"success": False,
"message": f"Invalid property name: {name!r}",
}
# Normalise scalar values to a spec dict with just {"value": ...}
if not isinstance(spec, dict):
spec = {"value": spec}
try:
block_text, action = self._set_property_in_block(
block_text, name, spec, comp_origin
)
except ValueError as ve:
return {"success": False, "message": str(ve)}
target = properties_added if action == "added" else properties_updated
target[name] = spec.get("value")
properties_removed: list = []
if remove_properties:
if not isinstance(remove_properties, list):
return {
"success": False,
"message": "removeProperties must be a list of property names",
}
for name in remove_properties:
block_text, removed = self._remove_property_from_block(block_text, name)
if removed:
properties_removed.append(name)
content = content[:block_start] + block_text + content[block_end + 1 :]
with open(sch_file, "w", encoding="utf-8") as f:
f.write(content)
changes = {
changes: Dict[str, Any] = {
k: v
for k, v in {
"footprint": new_footprint,
@@ -999,6 +1269,13 @@ class KiCADInterface:
}
if field_positions is not None:
changes["fieldPositions"] = field_positions
if properties_added:
changes["propertiesAdded"] = properties_added
if properties_updated:
changes["propertiesUpdated"] = properties_updated
if properties_removed:
changes["propertiesRemoved"] = properties_removed
logger.info(f"Edited schematic component {reference}: {changes}")
return {"success": True, "reference": reference, "updated": changes}
@@ -1009,6 +1286,52 @@ class KiCADInterface:
logger.error(traceback.format_exc())
return {"success": False, "message": str(e)}
def _handle_set_schematic_component_property(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Add or update a single property on a placed schematic symbol.
Convenience wrapper around `edit_schematic_component` for the very common
case of setting one BOM/sourcing field at a time. The property is created
if it does not already exist, otherwise its value (and optionally its
position / visibility) is updated in place.
"""
logger.info("Setting schematic component property")
name = params.get("name")
if not isinstance(name, str) or not name:
return {"success": False, "message": "name is required"}
if "value" not in params:
return {"success": False, "message": "value is required"}
spec: Dict[str, Any] = {"value": params["value"]}
for key in ("x", "y", "angle", "hide", "fontSize"):
if params.get(key) is not None:
spec[key] = params[key]
return self._handle_edit_schematic_component(
{
"schematicPath": params.get("schematicPath"),
"reference": params.get("reference"),
"properties": {name: spec},
}
)
def _handle_remove_schematic_component_property(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Remove a single custom property from a placed schematic symbol.
Built-in fields (Reference, Value, Footprint, Datasheet) cannot be
removed — use `edit_schematic_component` to clear them instead.
"""
logger.info("Removing schematic component property")
name = params.get("name")
if not isinstance(name, str) or not name:
return {"success": False, "message": "name is required"}
return self._handle_edit_schematic_component(
{
"schematicPath": params.get("schematicPath"),
"reference": params.get("reference"),
"removeProperties": [name],
}
)
def _handle_get_schematic_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Return full component info: position and all field values with their (at x y angle) positions."""
logger.info("Getting schematic component info")