diff --git a/CHANGELOG.md b/CHANGELOG.md index 40a1483..5b64ab6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,44 +4,42 @@ All notable changes to the KiCAD MCP Server project are documented here. ## [2.2.1-alpha] - 2026-02-28 +### New MCP Tools + +- `edit_schematic_component` – Update properties of a placed symbol in-place (footprint, + value, reference rename). More efficient than delete + re-add: preserves position and UUID. + ### Bug Fixes +- `add_schematic_component`: `footprint` parameter was accepted but silently ignored – the + value was never passed through to `DynamicSymbolLoader.add_component()` / + `create_component_instance()`. All newly placed symbols always had an empty Footprint + field. Fix: added `footprint: str = ""` to both functions and threaded it through every + call site including the TypeScript tool schema. + +- `delete_schematic_component`: only deleted the first matching instance when duplicate + references existed (e.g. after an aborted add attempt). Root cause: loop used `break` + after the first match. Fix: collect all matching blocks first, then delete them all back- + to-front (to preserve line indices). Response now includes `deleted_count`. + - `templates/*.kicad_sch`, `project.py`, `schematic.py`: Update KiCAD schematic format - version from `20230121` (KiCAD 7) to `20250114` (KiCAD 9). + version from `20230121` (KiCAD 7) to `20250114` (KiCAD 9). The MCP server targets + KiCAD 9 exclusively (`pcbnew.pyd` compiled for KiCAD 9.0, Python 3.11.5) – generating + files in an outdated format caused a spurious "This file was created with an older + KiCAD version" warning on every newly created schematic. - **Root cause:** The MCP server targets KiCAD 9 exclusively – `pcbnew.pyd` is compiled - for KiCAD 9.0 / Python 3.11.5, and the server explicitly selects the KiCAD 9 bundled - Python on Windows. Generating new schematics with a 2-year-old format tag caused a - spurious "This file was created with an older KiCAD version, it will be updated on - save" dialog on every newly created schematic, which confused users into thinking - something was broken. +- `template_with_symbols_expanded.kicad_sch`: Remove 13 corrupt `_TEMPLATE_*` placed-symbol + blocks with `(lib_id -100)` – an integer caused by old sexpdata serializer (same bug + PR #40 fixed for the add path). KiCAD crashed with a null-pointer when selecting these + symbols. They appeared as grey `_TEMPLATE_R?`, `_TEMPLATE_U_REG?` etc. labels far + outside the sheet boundary (~5000mm off-sheet). - **Fix:** Updated header in all 4 template files and both inline schematic generators - (`project.py`, `schematic.py`) to emit `(version 20250114)`. + **Discovered via:** live testing on a real JLCPCB/KiCAD 9 project. + **Affected users:** schematics created from this template before this fix contain the + same corrupt blocks – remove all `(symbol (lib_id -100) ...)` blocks whose Reference + starts with `_TEMPLATE_`. -- `template_with_symbols_expanded.kicad_sch`: Remove 13 corrupt `_TEMPLATE_*` - placed-symbol blocks containing `(lib_id -100)`. - - **Root cause:** The expanded template was generated by the old sexpdata serializer - (the same corruption PR #40 fixed for the DynamicSymbolLoader add-path). The serializer - converted the string `"Device:R"` to the integer `-100`, producing `(lib_id -100)` - instead of `(lib_id "Device:R")`. KiCAD cannot resolve an integer as a library - reference and crashes with a null-pointer when the user attempts to select or box-select - these symbols. They appeared as grey `_TEMPLATE_R?`, `_TEMPLATE_C?`, `_TEMPLATE_U_REG?` - etc. labels ~5000 mm outside the sheet boundary – invisible during normal work but - triggering a crash when accidentally selected. - - **Discovered via:** Live testing on a real JLCPCB/KiCAD 9 project where Claude Desktop - (via MCP) created a schematic from the expanded template. - - **Fix:** Removed all 13 `_TEMPLATE_*` placed-symbol blocks using parenthesis-depth - tracking (same text-manipulation approach as PR #40). The `lib_symbols` section - (symbol definitions) is unchanged – only the corrupt placed instances were removed. - - **Affected users:** Any schematic created from `template_with_symbols_expanded.kicad_sch` - before this fix contains the same corrupt blocks. To clean an existing schematic, - remove all `(symbol (lib_id -100) ...)` blocks whose `Reference` property starts - with `_TEMPLATE_`. +--- --- diff --git a/python/commands/dynamic_symbol_loader.py b/python/commands/dynamic_symbol_loader.py index b06f230..dcf4373 100644 --- a/python/commands/dynamic_symbol_loader.py +++ b/python/commands/dynamic_symbol_loader.py @@ -14,7 +14,7 @@ import logging from pathlib import Path from typing import Dict, List, Optional, Tuple -logger = logging.getLogger('kicad_interface') +logger = logging.getLogger("kicad_interface") class DynamicSymbolLoader: @@ -43,7 +43,7 @@ class DynamicSymbolLoader: Path.home() / ".local" / "share" / "kicad" / "9.0" / "symbols", Path.home() / "Documents" / "KiCad" / "9.0" / "3rdparty" / "symbols", ] - for env_var in ['KICAD9_SYMBOL_DIR', 'KICAD8_SYMBOL_DIR', 'KICAD_SYMBOL_DIR']: + for env_var in ["KICAD9_SYMBOL_DIR", "KICAD8_SYMBOL_DIR", "KICAD_SYMBOL_DIR"]: if env_var in os.environ: possible_paths.insert(0, Path(os.environ[env_var])) @@ -63,13 +63,14 @@ class DynamicSymbolLoader: Extract a complete symbol block from a library or schematic file by matching parentheses depth. Returns the raw text of the symbol definition. """ - lines = text.split('\n') + lines = text.split("\n") start = None for i, line in enumerate(lines): stripped = line.strip() # Match exact symbol name (not sub-symbols like Name_0_1) - if stripped.startswith(f'(symbol "{symbol_name}"') and \ - not re.match(r'.*_\d+_\d+"', stripped): + if stripped.startswith(f'(symbol "{symbol_name}"') and not re.match( + r'.*_\d+_\d+"', stripped + ): start = i break @@ -80,9 +81,9 @@ class DynamicSymbolLoader: end = None for i in range(start, len(lines)): for ch in lines[i]: - if ch == '(': + if ch == "(": depth += 1 - elif ch == ')': + elif ch == ")": depth -= 1 if depth == 0: end = i @@ -93,9 +94,11 @@ class DynamicSymbolLoader: if end is None: return None - return '\n'.join(lines[start:end + 1]) + return "\n".join(lines[start : end + 1]) - def extract_symbol_from_library(self, library_name: str, symbol_name: str) -> Optional[str]: + def extract_symbol_from_library( + self, library_name: str, symbol_name: str + ) -> Optional[str]: """ Extract a symbol definition from a KiCad .kicad_sym library file. Returns the raw text block, ready to be injected into a schematic. @@ -112,12 +115,14 @@ class DynamicSymbolLoader: if not lib_path: return None - with open(lib_path, 'r', encoding='utf-8') as f: + with open(lib_path, "r", encoding="utf-8") as f: lib_content = f.read() block = self._extract_symbol_block(lib_content, symbol_name) if block is None: - logger.warning(f"Symbol '{symbol_name}' not found in {library_name}.kicad_sym") + logger.warning( + f"Symbol '{symbol_name}' not found in {library_name}.kicad_sym" + ) return None # Check if this symbol uses (extends "ParentName") @@ -125,14 +130,16 @@ class DynamicSymbolLoader: parent_block = None if extends_match: parent_name = extends_match.group(1) - logger.info(f"Symbol {symbol_name} extends {parent_name}, extracting parent too") + logger.info( + f"Symbol {symbol_name} extends {parent_name}, extracting parent too" + ) parent_block = self._extract_symbol_block(lib_content, parent_name) if parent_block: # Prefix parent top-level name with library parent_block = parent_block.replace( f'(symbol "{parent_name}"', f'(symbol "{library_name}:{parent_name}"', - 1 # Only first occurrence (top-level) + 1, # Only first occurrence (top-level) ) # Prefix top-level symbol name with library @@ -140,13 +147,13 @@ class DynamicSymbolLoader: block = block.replace( f'(symbol "{symbol_name}"', f'(symbol "{full_name}"', - 1 # Only first occurrence (top-level) + 1, # Only first occurrence (top-level) ) # Sub-symbols like "Name_0_1" keep their short names (already correct from library) # Combine parent + child if extends is used if parent_block: - result = parent_block + '\n' + block + result = parent_block + "\n" + block else: result = block @@ -154,14 +161,16 @@ class DynamicSymbolLoader: logger.info(f"Extracted symbol {full_name} ({len(result)} chars)") return result - def inject_symbol_into_schematic(self, schematic_path: Path, library_name: str, symbol_name: str) -> bool: + def inject_symbol_into_schematic( + self, schematic_path: Path, library_name: str, symbol_name: str + ) -> bool: """ Inject a symbol definition into a schematic's lib_symbols section. Uses text manipulation to preserve file formatting. """ full_name = f"{library_name}:{symbol_name}" - with open(schematic_path, 'r', encoding='utf-8') as f: + with open(schematic_path, "r", encoding="utf-8") as f: content = f.read() # Check if symbol already exists @@ -172,36 +181,38 @@ class DynamicSymbolLoader: # Extract symbol from library symbol_block = self.extract_symbol_from_library(library_name, symbol_name) if not symbol_block: - raise ValueError(f"Symbol '{symbol_name}' not found in library '{library_name}'") + raise ValueError( + f"Symbol '{symbol_name}' not found in library '{library_name}'" + ) # Indent the block to match lib_symbols indentation (4 spaces for top-level) indented_lines = [] - for line in symbol_block.split('\n'): + for line in symbol_block.split("\n"): # Add 4-space indent for the content inside lib_symbols - indented_lines.append(' ' + line if line.strip() else line) - indented_block = '\n'.join(indented_lines) + indented_lines.append(" " + line if line.strip() else line) + indented_block = "\n".join(indented_lines) # Find the end of lib_symbols section to insert before closing ) - lines = content.split('\n') + lines = content.split("\n") lib_sym_start = None lib_sym_end = None depth = 0 for i, line in enumerate(lines): - if '(lib_symbols' in line and lib_sym_start is None: + if "(lib_symbols" in line and lib_sym_start is None: lib_sym_start = i depth = 0 for ch in line: - if ch == '(': + if ch == "(": depth += 1 - elif ch == ')': + elif ch == ")": depth -= 1 continue if lib_sym_start is not None and lib_sym_end is None: for ch in line: - if ch == '(': + if ch == "(": depth += 1 - elif ch == ')': + elif ch == ")": depth -= 1 if depth == 0: lib_sym_end = i @@ -215,15 +226,29 @@ class DynamicSymbolLoader: # Insert the symbol block just before the closing ) of lib_symbols lines.insert(lib_sym_end, indented_block) - with open(schematic_path, 'w', encoding='utf-8') as f: - f.write('\n'.join(lines)) + with open(schematic_path, "w", encoding="utf-8") as f: + f.write("\n".join(lines)) - logger.info(f"Injected symbol {full_name} into {schematic_path.name}") + # Handle both Path objects and strings + sch_name = ( + schematic_path.name + if hasattr(schematic_path, "name") + else str(schematic_path) + ) + logger.info(f"Injected symbol {full_name} into {sch_name}") return True - def create_component_instance(self, schematic_path: Path, library_name: str, - symbol_name: str, reference: str, - value: str = "", x: float = 0, y: float = 0) -> bool: + def create_component_instance( + self, + schematic_path: Path, + library_name: str, + symbol_name: str, + reference: str, + value: str = "", + footprint: str = "", + x: float = 0, + y: float = 0, + ) -> bool: """ Add a component instance to the schematic. This creates the (symbol ...) block with lib_id reference. @@ -231,7 +256,7 @@ class DynamicSymbolLoader: full_lib_id = f"{library_name}:{symbol_name}" new_uuid = str(uuid.uuid4()) - instance_block = f''' (symbol (lib_id "{full_lib_id}") (at {x} {y} 0) (unit 1) + instance_block = f""" (symbol (lib_id "{full_lib_id}") (at {x} {y} 0) (unit 1) (in_bom yes) (on_board yes) (dnp no) (uuid "{new_uuid}") (property "Reference" "{reference}" (at {x} {y - 2.54} 0) @@ -240,30 +265,30 @@ class DynamicSymbolLoader: (property "Value" "{value or symbol_name}" (at {x} {y + 2.54} 0) (effects (font (size 1.27 1.27))) ) - (property "Footprint" "" (at {x} {y} 0) + (property "Footprint" "{footprint}" (at {x} {y} 0) (effects (font (size 1.27 1.27)) (hide yes)) ) (property "Datasheet" "~" (at {x} {y} 0) (effects (font (size 1.27 1.27)) (hide yes)) ) - )''' + )""" - with open(schematic_path, 'r', encoding='utf-8') as f: + with open(schematic_path, "r", encoding="utf-8") as f: content = f.read() # Insert before (sheet_instances or at end before final ) - lines = content.split('\n') + lines = content.split("\n") insert_pos = None for i, line in enumerate(lines): - if '(sheet_instances' in line: + if "(sheet_instances" in line: insert_pos = i break if insert_pos is None: # Insert before the last closing parenthesis for i in range(len(lines) - 1, -1, -1): - if lines[i].strip() == ')': + if lines[i].strip() == ")": insert_pos = i break @@ -272,13 +297,17 @@ class DynamicSymbolLoader: lines.insert(insert_pos, instance_block) - with open(schematic_path, 'w', encoding='utf-8') as f: - f.write('\n'.join(lines)) + with open(schematic_path, "w", encoding="utf-8") as f: + f.write("\n".join(lines)) - logger.info(f"Added component instance {reference} ({full_lib_id}) at ({x}, {y})") + logger.info( + f"Added component instance {reference} ({full_lib_id}) at ({x}, {y})" + ) return True - def load_symbol_dynamically(self, schematic_path: Path, library_name: str, symbol_name: str) -> str: + def load_symbol_dynamically( + self, schematic_path: Path, library_name: str, symbol_name: str + ) -> str: """ Complete workflow: inject symbol definition and create a template instance. Returns a template reference name. @@ -289,21 +318,34 @@ class DynamicSymbolLoader: self.inject_symbol_into_schematic(schematic_path, library_name, symbol_name) # Step 2: Create an offscreen template instance - lib_clean = library_name.replace('-', '_').replace('.', '_') - sym_clean = symbol_name.replace('-', '_').replace('.', '_') + lib_clean = library_name.replace("-", "_").replace(".", "_") + sym_clean = symbol_name.replace("-", "_").replace(".", "_") template_ref = f"_TEMPLATE_{lib_clean}_{sym_clean}" self.create_component_instance( - schematic_path, library_name, symbol_name, - reference=template_ref, value=symbol_name, - x=-200, y=-200 + schematic_path, + library_name, + symbol_name, + reference=template_ref, + value=symbol_name, + x=-200, + y=-200, ) logger.info(f"Symbol loaded. Template reference: {template_ref}") return template_ref - def add_component(self, schematic_path: Path, library_name: str, symbol_name: str, - reference: str, value: str = "", x: float = 0, y: float = 0) -> bool: + def add_component( + self, + schematic_path: Path, + library_name: str, + symbol_name: str, + reference: str, + value: str = "", + footprint: str = "", + x: float = 0, + y: float = 0, + ) -> bool: """ High-level: ensure symbol definition exists in schematic, then add an instance. This is the main entry point for adding components. @@ -313,12 +355,18 @@ class DynamicSymbolLoader: # Add the component instance return self.create_component_instance( - schematic_path, library_name, symbol_name, - reference=reference, value=value, x=x, y=y + schematic_path, + library_name, + symbol_name, + reference=reference, + value=value, + footprint=footprint, + x=x, + y=y, ) -if __name__ == '__main__': +if __name__ == "__main__": logging.basicConfig(level=logging.INFO) loader = DynamicSymbolLoader() @@ -329,7 +377,12 @@ if __name__ == '__main__': print(f" Found {len(lib_dirs)} directories") print("\n2. Extracting symbols...") - for lib, sym in [('Device', 'R'), ('Device', 'C'), ('Device', 'LED'), ('Device', 'Q_NMOS')]: + for lib, sym in [ + ("Device", "R"), + ("Device", "C"), + ("Device", "LED"), + ("Device", "Q_NMOS"), + ]: block = loader.extract_symbol_from_library(lib, sym) if block: print(f" OK: {lib}:{sym} ({len(block)} chars)") @@ -337,8 +390,8 @@ if __name__ == '__main__': print(f" FAIL: {lib}:{sym}") print("\n3. Testing extends resolution...") - block = loader.extract_symbol_from_library('Regulator_Switching', 'LM2596S-5') - if block and 'LM2596S-12' in block: + block = loader.extract_symbol_from_library("Regulator_Switching", "LM2596S-5") + if block and "LM2596S-12" in block: print(f" OK: LM2596S-5 includes parent LM2596S-12 ({len(block)} chars)") else: print(f" FAIL: extends not resolved") diff --git a/python/kicad_interface.py b/python/kicad_interface.py index dcf1f54..e6f1a19 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -366,6 +366,7 @@ class KiCADInterface: "load_schematic": self._handle_load_schematic, "add_schematic_component": self._handle_add_schematic_component, "delete_schematic_component": self._handle_delete_schematic_component, + "edit_schematic_component": self._handle_edit_schematic_component, "add_schematic_wire": self._handle_add_schematic_wire, "add_schematic_connection": self._handle_add_schematic_connection, "add_schematic_net_label": self._handle_add_schematic_net_label, @@ -585,6 +586,7 @@ class KiCADInterface: library = component.get("library", "Device") reference = component.get("reference", "X?") value = component.get("value", comp_type) + footprint = component.get("footprint", "") x = component.get("x", 0) y = component.get("y", 0) @@ -595,6 +597,7 @@ class KiCADInterface: comp_type, reference=reference, value=value, + footprint=footprint, x=x, y=y, ) @@ -646,9 +649,8 @@ class KiCADInterface: lib_sym_end = i break - # Find the placed symbol block matching the reference - block_start = None - block_end = None + # Find ALL placed symbol blocks matching the reference (handles duplicates) + blocks_to_delete = [] i = 0 while i < len(lines): # Skip lib_symbols @@ -666,35 +668,33 @@ class KiCADInterface: j += 1 b_end = j - 1 - # Check if this block contains the target reference block_text = "\n".join(lines[b_start:b_end + 1]) - # Match: (property "Reference" "R1" ... if re.search(r'\(property\s+"Reference"\s+"' + re.escape(reference) + r'"', block_text): - block_start = b_start - block_end = b_end - break + blocks_to_delete.append((b_start, b_end)) i = b_end + 1 continue i += 1 - if block_start is None: + if not blocks_to_delete: return { "success": False, "message": f"Component '{reference}' not found in schematic (note: this tool removes schematic symbols, use delete_component for PCB footprints)", } - # Remove the block (and any trailing blank line) - del lines[block_start:block_end + 1] - if block_start < len(lines) and lines[block_start].strip() == "": - del lines[block_start] + # Delete from back to front to preserve line indices + for b_start, b_end in sorted(blocks_to_delete, reverse=True): + del lines[b_start:b_end + 1] + if b_start < len(lines) and lines[b_start].strip() == "": + del lines[b_start] with open(sch_file, "w", encoding="utf-8") as f: f.write("\n".join(lines)) - logger.info(f"Deleted schematic component {reference} from {sch_file.name}") - return {"success": True, "reference": reference, "schematic": str(sch_file)} + deleted_count = len(blocks_to_delete) + logger.info(f"Deleted {deleted_count} instance(s) of {reference} from {sch_file.name}") + return {"success": True, "reference": reference, "deleted_count": deleted_count, "schematic": str(sch_file)} except Exception as e: logger.error(f"Error deleting schematic component: {e}") @@ -702,6 +702,98 @@ class KiCADInterface: logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} + def _handle_edit_schematic_component(self, params): + """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.""" + logger.info("Editing schematic component") + try: + from pathlib import Path + import re + + schematic_path = params.get("schematicPath") + reference = params.get("reference") + new_footprint = params.get("footprint") + new_value = params.get("value") + new_reference = params.get("newReference") + + if not schematic_path: + return {"success": False, "message": "schematicPath is required"} + if not reference: + return {"success": False, "message": "reference is required"} + if not any([new_footprint is not None, new_value is not None, new_reference is not None]): + return {"success": False, "message": "At least one of footprint, value, or newReference must be provided"} + + sch_file = Path(schematic_path) + if not sch_file.exists(): + return {"success": False, "message": f"Schematic not found: {schematic_path}"} + + with open(sch_file, "r", encoding="utf-8") as f: + lines = f.read().split("\n") + + # Find lib_symbols range to skip + lib_sym_start, lib_sym_end = None, None + depth = 0 + for i, line in enumerate(lines): + if "(lib_symbols" in line and lib_sym_start is None: + lib_sym_start = i + depth = sum(1 for c in line if c == "(") - sum(1 for c in line if c == ")") + elif lib_sym_start is not None and lib_sym_end is None: + depth += sum(1 for c in line if c == "(") - sum(1 for c in line if c == ")") + if depth == 0: + lib_sym_end = i + break + + # Find the placed symbol block + block_start = block_end = None + i = 0 + while i < len(lines): + if lib_sym_start is not None and lib_sym_end is not None: + if lib_sym_start <= i <= lib_sym_end: + i += 1 + continue + if re.match(r"\s*\(symbol\s+\(lib_id\s+\"", lines[i]): + b_start = i + b_depth = sum(1 for c in lines[i] if c == "(") - sum(1 for c in lines[i] if c == ")") + j = i + 1 + while j < len(lines) and b_depth > 0: + b_depth += sum(1 for c in lines[j] if c == "(") - sum(1 for c in lines[j] if c == ")") + j += 1 + b_end = j - 1 + block_text = "\n".join(lines[b_start:b_end + 1]) + if re.search(r'\(property\s+"Reference"\s+"' + re.escape(reference) + r'"', block_text): + block_start, block_end = b_start, b_end + break + i = b_end + 1 + continue + i += 1 + + if block_start is None: + return {"success": False, "message": f"Component '{reference}' not found in schematic"} + + # Apply in-place property updates within the block + for k in range(block_start, block_end + 1): + line = lines[k] + if new_footprint is not None and re.match(r'\s*\(property\s+"Footprint"\s+"', line): + line = re.sub(r'(\(property\s+"Footprint"\s+)"[^"]*"', rf'\1"{new_footprint}"', line) + if new_value is not None and re.match(r'\s*\(property\s+"Value"\s+"', line): + line = re.sub(r'(\(property\s+"Value"\s+)"[^"]*"', rf'\1"{new_value}"', line) + if new_reference is not None and re.match(r'\s*\(property\s+"Reference"\s+"', line): + line = re.sub(r'(\(property\s+"Reference"\s+)"[^"]*"', rf'\1"{new_reference}"', line) + lines[k] = line + + with open(sch_file, "w", encoding="utf-8") as f: + f.write("\n".join(lines)) + + changes = {k: v for k, v in {"footprint": new_footprint, "value": new_value, "reference": new_reference}.items() if v is not None} + logger.info(f"Edited schematic component {reference}: {changes}") + return {"success": True, "reference": reference, "updated": changes} + + except Exception as e: + logger.error(f"Error editing schematic component: {e}") + import traceback + logger.error(traceback.format_exc()) + return {"success": False, "message": str(e)} + def _handle_add_schematic_wire(self, params): """Add a wire to a schematic using WireManager""" logger.info("Adding wire to schematic") diff --git a/src/tools/schematic.ts b/src/tools/schematic.ts index 23f9ff1..4db17fc 100644 --- a/src/tools/schematic.ts +++ b/src/tools/schematic.ts @@ -43,6 +43,7 @@ export function registerSchematicTools( ), reference: z.string().describe("Component reference (e.g., R1, U1)"), value: z.string().optional().describe("Component value"), + footprint: z.string().optional().describe("KiCAD footprint (e.g. Resistor_SMD:R_0603_1608Metric)"), position: z .object({ x: z.number(), @@ -56,6 +57,7 @@ export function registerSchematicTools( symbol: string; reference: string; value?: string; + footprint?: string; position?: { x: number; y: number }; }) => { // Transform to what Python backend expects @@ -70,6 +72,7 @@ export function registerSchematicTools( type: symbolName, reference: args.reference, value: args.value, + footprint: args.footprint ?? "", // Python expects flat x, y not nested position x: args.position?.x ?? 0, y: args.position?.y ?? 0, @@ -141,6 +144,55 @@ To remove a footprint from a PCB, use delete_component instead.`, }, ); + // Edit component properties in schematic (footprint, value, reference) + server.tool( + "edit_schematic_component", + `Update properties of a placed symbol in a KiCAD schematic (.kicad_sch) in-place. + +Use this tool to assign or update a footprint, change the value, or rename the reference +of an already-placed component. This is more efficient than delete + re-add because it +preserves the component's position and UUID. + +Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_component.`, + { + schematicPath: z.string().describe("Path to the .kicad_sch file"), + reference: z.string().describe("Current reference designator of the component (e.g. R1, U3)"), + footprint: z.string().optional().describe("New KiCAD footprint string (e.g. Resistor_SMD:R_0603_1608Metric)"), + value: z.string().optional().describe("New value string (e.g. 10k, 100nF)"), + newReference: z.string().optional().describe("Rename the reference designator (e.g. R1 → R10)"), + }, + async (args: { + schematicPath: string; + reference: string; + footprint?: string; + value?: string; + newReference?: string; + }) => { + const result = await callKicadScript("edit_schematic_component", args); + if (result.success) { + const changes = Object.entries(result.updated ?? {}) + .map(([k, v]) => `${k}=${v}`) + .join(", "); + return { + content: [ + { + type: "text" as const, + text: `Successfully updated ${args.reference}: ${changes}`, + }, + ], + }; + } + return { + content: [ + { + type: "text" as const, + text: `Failed to edit component: ${result.message || "Unknown error"}`, + }, + ], + }; + }, + ); + // Connect components with wire server.tool( "add_wire",