kicad-mcp/src/mckicad/tools/schematic_edit.py
Ryan Malloy 57872e59c1 Replace kicad-sch-api with internal SchDocument engine
Eliminate the external kicad-sch-api dependency by migrating all MCP
tools to the internal SchDocument class built on sexp_tree.py. This
fixes three serialization bugs (dropped global labels, TypeError on
local labels, mis-quoted property private keywords) and removes ~1,900
lines of workaround code.

New modules:
- sexp_tree.py: S-expression parser and round-trip serializer
- sch_document.py: schematic read/write/mutate API
- lib_resolver.py: symbol library search and resolution
- sch_helpers.py: shared load/validate/expand helpers

Migrated all 9 tool files, resources, and tests. Removed 5 workaround
functions from sexp_parser.py. 531 tests pass, ruff + mypy clean.
2026-07-11 16:47:22 -06:00

631 lines
21 KiB
Python

"""
Schematic editing tools for modifying existing elements in KiCad schematics.
Complements the creation-oriented tools in schematic.py with modification,
removal, and annotation capabilities.
Also includes s-expression-direct tools (e.g. ``remove_wires_by_criteria``)
that bypass the schematic engine for operations that work directly on the
s-expression tree.
"""
import logging
from typing import Any
from mckicad.server import mcp
from mckicad.utils.sch_helpers import expand as _expand
from mckicad.utils.sch_helpers import load_schematic, validate_schematic_path as _validate_schematic_path
from mckicad.utils.sexp_parser import (
parse_wire_segments,
remove_sexp_blocks_by_uuid,
)
logger = logging.getLogger(__name__)
def _get_schematic_engine() -> str:
return "sch_document"
# ---------------------------------------------------------------------------
# Tools
# ---------------------------------------------------------------------------
@mcp.tool()
def modify_component(
schematic_path: str,
reference: str,
x: float | None = None,
y: float | None = None,
rotation: float | None = None,
value: str | None = None,
footprint: str | None = None,
properties: dict[str, str] | None = None,
in_bom: bool | None = None,
on_board: bool | None = None,
) -> dict[str, Any]:
"""Edit an existing component in a KiCad schematic.
Supports moving, rotating, changing value/footprint, updating arbitrary
properties, and toggling BOM/board inclusion -- all in a single call.
Only the parameters you provide will be changed; everything else is left
untouched.
Args:
schematic_path: Path to an existing .kicad_sch file.
reference: Reference designator of the component to modify (e.g. ``R1``, ``U3``).
x: New X position. Must be provided together with ``y`` to move.
y: New Y position. Must be provided together with ``x`` to move.
rotation: New rotation angle in degrees (e.g. 0, 90, 180, 270).
value: New value string (e.g. ``4.7k``, ``100nF``).
footprint: New footprint identifier (e.g. ``Resistor_SMD:R_0603_1608Metric``).
properties: Dictionary of property names to values to set or update.
in_bom: Set to True/False to include or exclude the component from BOM output.
on_board: Set to True/False to include or exclude the component on the PCB.
Returns:
Dictionary with ``success``, ``reference``, ``modified`` list, and ``engine``.
"""
verr = _validate_schematic_path(schematic_path)
if verr:
return verr
schematic_path = _expand(schematic_path)
if not reference:
return {"success": False, "error": "reference must be a non-empty string"}
# Validate that move coordinates come in pairs
if (x is None) != (y is None):
return {
"success": False,
"error": "Both x and y must be provided together to move a component",
}
try:
sch = load_schematic(schematic_path)
comp = sch.components.get(reference)
if comp is None:
return {
"success": False,
"error": f"Component '{reference}' not found in schematic",
"schematic_path": schematic_path,
}
modified: list[str] = []
if x is not None and y is not None:
comp.move(x, y)
modified.append(f"position=({x}, {y})")
if rotation is not None:
comp.rotate(rotation)
modified.append(f"rotation={rotation}")
if value is not None:
comp.set_value(value)
modified.append(f"value={value}")
if footprint is not None:
comp.set_footprint(footprint)
modified.append(f"footprint={footprint}")
if properties:
for key, val in properties.items():
comp.set_property(key, val)
modified.append(f"property[{key}]={val}")
if in_bom is not None:
comp.set_in_bom(in_bom)
modified.append(f"in_bom={in_bom}")
if on_board is not None:
comp.set_on_board(on_board)
modified.append(f"on_board={on_board}")
if not modified:
return {
"success": True,
"reference": reference,
"modified": [],
"note": "No changes requested -- component left unchanged",
"schematic_path": schematic_path,
"engine": _get_schematic_engine(),
}
sch.save(schematic_path)
logger.info("Modified %s in %s: %s", reference, schematic_path, ", ".join(modified))
return {
"success": True,
"reference": reference,
"modified": modified,
"schematic_path": schematic_path,
"engine": _get_schematic_engine(),
}
except Exception as e:
logger.error("Failed to modify component %s in %s: %s", reference, schematic_path, e)
return {
"success": False,
"error": str(e),
"reference": reference,
"schematic_path": schematic_path,
}
@mcp.tool()
def remove_component(schematic_path: str, reference: str) -> dict[str, Any]:
"""Remove a component from a KiCad schematic by its reference designator.
The component and all its associated properties are deleted from the
schematic file. Wires connected to the component's pins are *not*
automatically removed -- use ``remove_wire`` to clean those up if needed.
Args:
schematic_path: Path to an existing .kicad_sch file.
reference: Reference designator of the component to remove (e.g. ``R1``, ``U3``).
Returns:
Dictionary with ``success``, removed ``reference``, and ``engine``.
"""
verr = _validate_schematic_path(schematic_path)
if verr:
return verr
schematic_path = _expand(schematic_path)
if not reference:
return {"success": False, "error": "reference must be a non-empty string"}
try:
sch = load_schematic(schematic_path)
removed = sch.components.remove(reference)
if removed is False:
return {
"success": False,
"error": f"Component '{reference}' not found in schematic",
"reference": reference,
"schematic_path": schematic_path,
}
sch.save(schematic_path)
logger.info("Removed component %s from %s", reference, schematic_path)
return {
"success": True,
"reference": reference,
"schematic_path": schematic_path,
"engine": _get_schematic_engine(),
}
except Exception as e:
logger.error("Failed to remove component %s from %s: %s", reference, schematic_path, e)
return {
"success": False,
"error": str(e),
"reference": reference,
"schematic_path": schematic_path,
}
@mcp.tool()
def remove_wire(schematic_path: str, wire_id: str) -> dict[str, Any]:
"""Remove a wire segment from a KiCad schematic by its UUID.
Wire IDs are returned by ``add_wire`` and ``connect_pins``, or can be
found via ``get_schematic_info``. Removing a wire breaks the electrical
connection it represents.
Args:
schematic_path: Path to an existing .kicad_sch file.
wire_id: UUID of the wire segment to remove.
Returns:
Dictionary with ``success``, removed ``wire_id``, and ``engine``.
"""
verr = _validate_schematic_path(schematic_path)
if verr:
return verr
schematic_path = _expand(schematic_path)
if not wire_id:
return {"success": False, "error": "wire_id must be a non-empty string"}
try:
sch = load_schematic(schematic_path)
sch.remove_wire(wire_id)
sch.save(schematic_path)
logger.info("Removed wire %s from %s", wire_id, schematic_path)
return {
"success": True,
"wire_id": wire_id,
"schematic_path": schematic_path,
"engine": _get_schematic_engine(),
}
except Exception as e:
logger.error("Failed to remove wire %s from %s: %s", wire_id, schematic_path, e)
return {
"success": False,
"error": str(e),
"wire_id": wire_id,
"schematic_path": schematic_path,
}
@mcp.tool()
def remove_label(schematic_path: str, label_id: str) -> dict[str, Any]:
"""Remove a net label (local or global) from a KiCad schematic by its UUID.
Label IDs are returned by ``add_label`` or can be found via
``get_schematic_info``. Removing a label may disconnect nets that
relied on it for naming or inter-sheet connectivity.
Args:
schematic_path: Path to an existing .kicad_sch file.
label_id: UUID of the label to remove.
Returns:
Dictionary with ``success``, removed ``label_id``, and ``engine``.
"""
verr = _validate_schematic_path(schematic_path)
if verr:
return verr
schematic_path = _expand(schematic_path)
if not label_id:
return {"success": False, "error": "label_id must be a non-empty string"}
try:
sch = load_schematic(schematic_path)
sch.remove_label(label_id)
sch.save(schematic_path)
logger.info("Removed label %s from %s", label_id, schematic_path)
return {
"success": True,
"label_id": label_id,
"schematic_path": schematic_path,
"engine": _get_schematic_engine(),
}
except Exception as e:
logger.error("Failed to remove label %s from %s: %s", label_id, schematic_path, e)
return {
"success": False,
"error": str(e),
"label_id": label_id,
"schematic_path": schematic_path,
}
@mcp.tool()
def set_title_block(
schematic_path: str,
title: str | None = None,
author: str | None = None,
date: str | None = None,
revision: str | None = None,
company: str | None = None,
) -> dict[str, Any]:
"""Set or update the title block fields on a KiCad schematic.
The title block appears in the lower-right corner of printed schematics
and is embedded in exported PDFs. Only the fields you provide will be
changed; omitted fields keep their current values.
Args:
schematic_path: Path to an existing .kicad_sch file.
title: Schematic title (e.g. ``Motor Driver Rev B``).
author: Designer or team name.
date: Design date string (e.g. ``2026-03-03``).
revision: Revision identifier (e.g. ``1.2``, ``C``).
company: Company or organisation name.
Returns:
Dictionary with ``success``, ``fields_set`` list, and ``engine``.
"""
verr = _validate_schematic_path(schematic_path)
if verr:
return verr
schematic_path = _expand(schematic_path)
# Build kwargs for kicad-sch-api's set_title_block().
# API signature: set_title_block(title, date, rev, company, comments)
# There is no 'author' param — KiCad convention puts author in Comment1.
kwargs: dict[str, Any] = {}
fields_set: list[str] = []
if title is not None:
kwargs["title"] = title
fields_set.append("title")
if date is not None:
kwargs["date"] = date
fields_set.append("date")
if revision is not None:
kwargs["rev"] = revision
fields_set.append("revision")
if company is not None:
kwargs["company"] = company
fields_set.append("company")
if author is not None:
kwargs["comments"] = {1: author}
fields_set.append("author")
if not fields_set:
return {
"success": True,
"fields_set": [],
"note": "No fields provided -- title block left unchanged",
"schematic_path": schematic_path,
"engine": _get_schematic_engine(),
}
try:
sch = load_schematic(schematic_path)
sch.set_title_block(**kwargs)
sch.save(schematic_path)
logger.info("Set title block fields %s in %s", fields_set, schematic_path)
return {
"success": True,
"fields_set": fields_set,
"schematic_path": schematic_path,
"engine": _get_schematic_engine(),
}
except Exception as e:
logger.error("Failed to set title block in %s: %s", schematic_path, e)
return {"success": False, "error": str(e), "schematic_path": schematic_path}
@mcp.tool()
def add_text_annotation(
schematic_path: str,
text: str,
x: float,
y: float,
) -> dict[str, Any]:
"""Add a free-text annotation to a KiCad schematic.
Text annotations are non-electrical notes placed directly on the
schematic sheet -- useful for design notes, revision comments, or
pin-function callouts that don't affect the netlist.
Args:
schematic_path: Path to an existing .kicad_sch file.
text: The annotation text to display.
x: Horizontal position in schematic units.
y: Vertical position in schematic units.
Returns:
Dictionary with ``success``, ``text_id``, ``position``, and ``engine``.
"""
verr = _validate_schematic_path(schematic_path)
if verr:
return verr
schematic_path = _expand(schematic_path)
if not text:
return {"success": False, "error": "text must be a non-empty string"}
try:
sch = load_schematic(schematic_path)
text_id = sch.add_text(text, x, y)
sch.save(schematic_path)
logger.info("Added text annotation at (%.1f, %.1f) in %s", x, y, schematic_path)
return {
"success": True,
"text_id": text_id,
"text": text,
"position": {"x": x, "y": y},
"schematic_path": schematic_path,
"engine": _get_schematic_engine(),
}
except Exception as e:
logger.error("Failed to add text annotation in %s: %s", schematic_path, e)
return {"success": False, "error": str(e), "schematic_path": schematic_path}
@mcp.tool()
def add_no_connect(
schematic_path: str,
x: float,
y: float,
) -> dict[str, Any]:
"""Place a no-connect flag (X marker) on a schematic pin.
No-connect flags tell the ERC (electrical rules checker) that an
unconnected pin is intentional, suppressing false-positive warnings.
Place one on every unused pin to keep ERC results clean.
Args:
schematic_path: Path to an existing .kicad_sch file.
x: X position of the pin to mark as no-connect.
y: Y position of the pin to mark as no-connect.
Returns:
Dictionary with ``success``, ``position``, and ``engine``.
"""
verr = _validate_schematic_path(schematic_path)
if verr:
return verr
schematic_path = _expand(schematic_path)
try:
sch = load_schematic(schematic_path)
sch.add_no_connect(x, y)
sch.save(schematic_path)
logger.info("Added no-connect at (%.1f, %.1f) in %s", x, y, schematic_path)
return {
"success": True,
"position": {"x": x, "y": y},
"schematic_path": schematic_path,
"engine": _get_schematic_engine(),
}
except Exception as e:
logger.error("Failed to add no-connect in %s: %s", schematic_path, e)
return {"success": False, "error": str(e), "schematic_path": schematic_path}
@mcp.tool()
def backup_schematic(schematic_path: str) -> dict[str, Any]:
"""Create a timestamped backup of a KiCad schematic file.
Writes a copy of the schematic to a backup file alongside the original,
named with a timestamp so that multiple backups can coexist. Call this
before making destructive edits (bulk component removal, major rewiring)
to ensure you can recover the previous state.
Args:
schematic_path: Path to the .kicad_sch file to back up.
Returns:
Dictionary with ``success``, ``backup_path``, and ``engine``.
"""
verr = _validate_schematic_path(schematic_path)
if verr:
return verr
schematic_path = _expand(schematic_path)
try:
sch = load_schematic(schematic_path)
backup_path = sch.backup()
logger.info("Backed up %s to %s", schematic_path, backup_path)
return {
"success": True,
"original_path": schematic_path,
"backup_path": str(backup_path),
"engine": _get_schematic_engine(),
}
except Exception as e:
logger.error("Failed to back up %s: %s", schematic_path, e)
return {"success": False, "error": str(e), "schematic_path": schematic_path}
@mcp.tool()
def remove_wires_by_criteria(
schematic_path: str,
y: float | None = None,
x: float | None = None,
min_x: float | None = None,
max_x: float | None = None,
min_y: float | None = None,
max_y: float | None = None,
tolerance: float = 0.01,
dry_run: bool = False,
) -> dict[str, Any]:
"""Remove wire segments matching coordinate criteria from a KiCad schematic.
Uses direct s-expression manipulation to surgically remove wires
by their position — essential for cleaning up bulk wiring errors
like overlapping horizontal/vertical segments that create shorts.
All specified criteria must match for a wire to be selected.
At least one criterion is required (won't delete all wires).
Args:
schematic_path: Path to an existing .kicad_sch file.
y: Match horizontal wires where both endpoints have Y within
``tolerance`` of this value.
x: Match vertical wires where both endpoints have X within
``tolerance`` of this value.
min_x: Both endpoints' X values must be >= this value (minus tolerance).
max_x: Both endpoints' X values must be <= this value (plus tolerance).
min_y: Both endpoints' Y values must be >= this value (minus tolerance).
max_y: Both endpoints' Y values must be <= this value (plus tolerance).
tolerance: Coordinate comparison tolerance (default 0.01).
dry_run: If True, return matching wires without removing them.
Returns:
Dictionary with ``matched_count``, ``removed_count`` (0 if dry_run),
and ``matched_wires`` preview list.
"""
verr = _validate_schematic_path(schematic_path)
if verr:
return verr
schematic_path = _expand(schematic_path)
# Require at least one filtering criterion
has_criteria = any(v is not None for v in (y, x, min_x, max_x, min_y, max_y))
if not has_criteria:
return {
"success": False,
"error": "At least one coordinate criterion (y, x, min_x, max_x, min_y, max_y) is required",
}
try:
all_wires = parse_wire_segments(schematic_path)
if not all_wires:
return {
"success": True,
"matched_count": 0,
"removed_count": 0,
"note": "No wire segments found in schematic",
"schematic_path": schematic_path,
}
# Filter wires by criteria
matched: list[dict[str, Any]] = []
for wire in all_wires:
sx, sy = wire["start"]["x"], wire["start"]["y"]
ex, ey = wire["end"]["x"], wire["end"]["y"]
if y is not None and (abs(sy - y) > tolerance or abs(ey - y) > tolerance):
continue
if x is not None and (abs(sx - x) > tolerance or abs(ex - x) > tolerance):
continue
if min_x is not None and min(sx, ex) < min_x - tolerance:
continue
if max_x is not None and max(sx, ex) > max_x + tolerance:
continue
if min_y is not None and min(sy, ey) < min_y - tolerance:
continue
if max_y is not None and max(sy, ey) > max_y + tolerance:
continue
matched.append(wire)
if dry_run or not matched:
return {
"success": True,
"dry_run": dry_run,
"matched_count": len(matched),
"removed_count": 0,
"matched_wires": matched[:50], # cap preview
"schematic_path": schematic_path,
}
# Remove matched wires via s-expression manipulation
uuids_to_remove = {w["uuid"] for w in matched if w.get("uuid")}
if not uuids_to_remove:
return {
"success": False,
"error": "Matched wires have no UUIDs — cannot remove (file may be malformed)",
"matched_count": len(matched),
"schematic_path": schematic_path,
}
removed_count = remove_sexp_blocks_by_uuid(schematic_path, uuids_to_remove)
logger.info(
"remove_wires_by_criteria: removed %d/%d matched wires from %s",
removed_count, len(matched), schematic_path,
)
return {
"success": True,
"dry_run": False,
"matched_count": len(matched),
"removed_count": removed_count,
"matched_wires": matched[:50],
"schematic_path": schematic_path,
}
except Exception as e:
logger.error("remove_wires_by_criteria failed: %s", e, exc_info=True)
return {"success": False, "error": str(e), "schematic_path": schematic_path}