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.
631 lines
21 KiB
Python
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}
|