From 1ba86f776971f621b6ad0bdcacd8945bdefda77a Mon Sep 17 00:00:00 2001 From: Tom Date: Sat, 28 Feb 2026 13:48:35 +0100 Subject: [PATCH] feat: datasheet tools + fix missing delete_schematic_component New tools - datasheet: - get_datasheet_url: construct LCSC datasheet PDF URL + product page URL without any API key (URL schema: https://www.lcsc.com/datasheet/.pdf) - enrich_datasheets: scan .kicad_sch, write LCSC datasheet URL into every symbol that has an LCSC property but an empty Datasheet field; supports dry_run=true for preview; text-based implementation (no skip writes) Implementation: python/commands/datasheet_manager.py New tool - schematic: - delete_schematic_component: remove a placed symbol from a .kicad_sch file by reference designator (e.g. R1, U3) Bug fix - delete_schematic_component (two separate root causes): 1. No MCP tool named delete_schematic_component was registered at all. Any delete-symbol request fell through to the PCB-only delete_component tool which searches pcbnew.BOARD and always returned 'Component not found' for schematic symbols. 2. component_schematic.py::remove_component() still used skip for writes. PR #40 rewrote DynamicSymbolLoader (add path) to avoid skip-induced schematic corruption, but the delete path was not touched by that PR. Fix: _handle_delete_schematic_component in kicad_interface.py uses direct text manipulation with parenthesis-depth tracking (same technique as PR #40), bypassing component_schematic.py entirely. Error message explicitly guides users: 'use delete_component for PCB footprints'. Files changed: - python/commands/datasheet_manager.py (new) - src/tools/datasheet.ts (new) - python/kicad_interface.py: 3 new handlers + dispatch entries - src/tools/schematic.ts: delete_schematic_component tool - src/server.ts: registerDatasheetTools import + call - src/tools/index.ts: export registerDatasheetTools - CHANGELOG.md: document all above --- CHANGELOG.md | 39 ++ python/commands/datasheet_manager.py | 289 ++++++++++++ python/kicad_interface.py | 133 ++++++ src/server.ts | 2 + src/tools/datasheet.ts | 123 +++++ src/tools/index.ts | 31 +- src/tools/schematic.ts | 651 ++++++++++++++++----------- 7 files changed, 985 insertions(+), 283 deletions(-) create mode 100644 python/commands/datasheet_manager.py create mode 100644 src/tools/datasheet.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c355aec..6c6cfe5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,45 @@ All notable changes to the KiCAD MCP Server project are documented here. - `library.py`: Fix loop variable shadowing `Path` object (mypy) - `design_rules.py`: Add type annotation for `violation_counts` (mypy) +### Pending additions (not yet committed) + +**Datasheet tools:** +- `get_datasheet_url` - Return LCSC datasheet PDF URL and product page URL for a given + LCSC number (e.g. `C179739` → `https://www.lcsc.com/datasheet/C179739.pdf`). + No API key required – URL is constructed directly from the LCSC number. +- `enrich_datasheets` - Scan a `.kicad_sch` file and write LCSC datasheet URLs into + every symbol that has an `LCSC` property but an empty `Datasheet` field. After + enrichment the URL appears natively in KiCAD's symbol properties, footprint browser + and any other tool that reads the standard KiCAD `Datasheet` field. + Supports `dry_run=true` for preview without writing. + Implementation: `python/commands/datasheet_manager.py` (text-based, no `skip` writes) + +**Schematic tools:** +- `delete_schematic_component` - Remove a placed symbol from a `.kicad_sch` file by + reference designator (e.g. `R1`, `U3`). + +### Bug Fixes (pending) + +- `schematic.ts` / `kicad_interface.py`: Fix missing `delete_schematic_component` MCP tool. + + **Root cause (two separate issues):** + 1. No MCP tool named `delete_schematic_component` existed. Claude had no way to call + it, so any "delete schematic component" request fell through to the PCB-only + `delete_component` tool, which searches `pcbnew.BOARD` and always returned + "Component not found" for schematic symbols. + 2. `component_schematic.py::remove_component()` still used `skip` for writes. + PR #40 rewrote `DynamicSymbolLoader` (add path) to avoid `skip`-induced schematic + corruption, but `remove_component` (delete path) was not touched by that PR. + + **Fix:** + - Added `delete_schematic_component` to the TypeScript tool layer (`schematic.ts`) + with clear docstring distinguishing it from the PCB `delete_component`. + - Implemented `_handle_delete_schematic_component` in `kicad_interface.py` using + direct text manipulation (parenthesis-depth tracking, same approach as PR #40). + Does not call `component_schematic.py::remove_component()` at all. + - Error message explicitly guides the user when the wrong tool is used: + *"note: this tool removes schematic symbols, use delete_component for PCB footprints"* + ### Pending fixes (not yet committed) - `connection_schematic.py` / `kicad_interface.py`: Fix `generate_netlist` missing diff --git a/python/commands/datasheet_manager.py b/python/commands/datasheet_manager.py new file mode 100644 index 0000000..5fa051f --- /dev/null +++ b/python/commands/datasheet_manager.py @@ -0,0 +1,289 @@ +""" +Datasheet Manager for KiCAD MCP Server + +Enriches KiCAD schematic symbols with datasheet URLs derived from LCSC part numbers. +Uses direct text manipulation (like dynamic_symbol_loader.py) to avoid +skip-library-induced schematic corruption. + +URL schema: https://www.lcsc.com/datasheet/{LCSC#}.pdf +No API key required. +""" + +import re +import logging +from pathlib import Path +from typing import Dict, List, Optional + +logger = logging.getLogger("kicad_interface") + +LCSC_DATASHEET_URL = "https://www.lcsc.com/datasheet/{lcsc}.pdf" +LCSC_PRODUCT_URL = "https://www.lcsc.com/product-detail/{lcsc}.html" + +# Values treated as "empty" datasheet +EMPTY_DATASHEET_VALUES = {"~", "", "~{DATASHEET}"} + + +class DatasheetManager: + """ + Enriches KiCAD schematics with LCSC datasheet URLs. + + Reads .kicad_sch files, finds symbol instances that have an LCSC property + but an empty Datasheet property, and fills in the LCSC datasheet URL. + """ + + @staticmethod + def _normalize_lcsc(lcsc: str) -> Optional[str]: + """ + Normalize LCSC number to standard format 'C123456'. + + Accepts: 'C123456', '123456', 'c123456' + Returns: 'C123456' or None if invalid + """ + lcsc = lcsc.strip() + if not lcsc: + return None + # Remove leading C/c + without_prefix = lcsc.lstrip("Cc") + if without_prefix.isdigit(): + return f"C{without_prefix}" + return None + + @staticmethod + def _find_lib_symbols_range(lines: List[str]): + """ + Find the line range of the (lib_symbols ...) section. + Returns (start, end) line indices or (None, None) if not found. + These lines must be excluded from symbol-instance processing. + """ + 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: + lib_sym_start = i + depth = 0 + for ch in line: + if ch == "(": + depth += 1 + elif ch == ")": + depth -= 1 + elif lib_sym_start is not None and lib_sym_end is None: + for ch in line: + if ch == "(": + depth += 1 + elif ch == ")": + depth -= 1 + if depth == 0: + lib_sym_end = i + break + + return lib_sym_start, lib_sym_end + + @staticmethod + def _process_symbol_block( + lines: List[str], block_start: int, block_end: int + ) -> Optional[Dict]: + """ + Extract LCSC and Datasheet info from a placed symbol block. + + Returns dict with: + - lcsc: normalized LCSC number or None + - datasheet_line: line index of Datasheet property or None + - datasheet_value: current Datasheet value or None + """ + lcsc_value = None + datasheet_line_idx = None + datasheet_current = None + + for k in range(block_start, block_end + 1): + line = lines[k] + + lcsc_match = re.search(r'\(property\s+"LCSC"\s+"([^"]*)"', line) + if lcsc_match: + lcsc_value = lcsc_match.group(1) + + ds_match = re.search(r'\(property\s+"Datasheet"\s+"([^"]*)"', line) + if ds_match: + datasheet_line_idx = k + datasheet_current = ds_match.group(1) + + return { + "lcsc": lcsc_value, + "datasheet_line": datasheet_line_idx, + "datasheet_value": datasheet_current, + } + + def enrich_schematic( + self, schematic_path: Path, dry_run: bool = False + ) -> Dict: + """ + Scan a .kicad_sch file and fill in missing LCSC datasheet URLs. + + For each placed symbol that has: + - (property "LCSC" "C123456") set + - (property "Datasheet" "~") or empty + + Sets: + - (property "Datasheet" "https://www.lcsc.com/datasheet/C123456.pdf") + + Args: + schematic_path: Path to .kicad_sch file + dry_run: If True, return what would be changed without writing + + Returns: + { + "success": True, + "updated": , + "already_set": , + "no_lcsc": , + "no_datasheet_field": , + "details": [{"reference": "...", "lcsc": "...", "url": "..."}] + } + """ + schematic_path = Path(schematic_path) + if not schematic_path.exists(): + return { + "success": False, + "message": f"Schematic not found: {schematic_path}", + } + + with open(schematic_path, "r", encoding="utf-8") as f: + content = f.read() + + lines = content.split("\n") + new_lines = list(lines) + + lib_sym_start, lib_sym_end = self._find_lib_symbols_range(lines) + + updated = 0 + already_set = 0 + no_lcsc = 0 + no_datasheet_field = 0 + details = [] + + i = 0 + while i < len(new_lines): + line = new_lines[i] + + # Skip lib_symbols section + 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 + + # Detect placed symbol: (symbol (lib_id "...") + if re.match(r"\s*\(symbol\s+\(lib_id\s+\"", line): + block_start = i + block_depth = 0 + for ch in line: + if ch == "(": + block_depth += 1 + elif ch == ")": + block_depth -= 1 + + j = i + 1 + while j < len(new_lines) and block_depth > 0: + for ch in new_lines[j]: + if ch == "(": + block_depth += 1 + elif ch == ")": + block_depth -= 1 + if block_depth > 0: + j += 1 + else: + break + + block_end = j + info = self._process_symbol_block(new_lines, block_start, block_end) + + raw_lcsc = info["lcsc"] + ds_line = info["datasheet_line"] + ds_value = info["datasheet_value"] + + # Extract reference for reporting + ref_match = None + for k in range(block_start, block_end + 1): + m = re.search(r'\(property\s+"Reference"\s+"([^"]+)"', new_lines[k]) + if m: + ref_match = m.group(1) + break + reference = ref_match or "?" + + if not raw_lcsc: + no_lcsc += 1 + elif ds_line is None: + no_datasheet_field += 1 + logger.warning( + f"Symbol {reference} has LCSC={raw_lcsc} but no Datasheet property" + ) + else: + lcsc_norm = self._normalize_lcsc(raw_lcsc) + if not lcsc_norm: + no_lcsc += 1 + elif ds_value not in EMPTY_DATASHEET_VALUES: + already_set += 1 + logger.debug( + f"Symbol {reference}: Datasheet already set to {ds_value!r}" + ) + else: + url = LCSC_DATASHEET_URL.format(lcsc=lcsc_norm) + if not dry_run: + new_lines[ds_line] = re.sub( + r'(property\s+"Datasheet"\s+)"[^"]*"', + f'\\1"{url}"', + new_lines[ds_line], + ) + updated += 1 + details.append( + { + "reference": reference, + "lcsc": lcsc_norm, + "url": url, + "dry_run": dry_run, + } + ) + logger.info( + f"{'[DRY RUN] ' if dry_run else ''}Set Datasheet for " + f"{reference} ({lcsc_norm}): {url}" + ) + + i = block_end + 1 + continue + + i += 1 + + if not dry_run and updated > 0: + with open(schematic_path, "w", encoding="utf-8") as f: + f.write("\n".join(new_lines)) + logger.info( + f"Saved {schematic_path.name}: {updated} datasheet URLs written" + ) + + return { + "success": True, + "updated": updated, + "already_set": already_set, + "no_lcsc": no_lcsc, + "no_datasheet_field": no_datasheet_field, + "dry_run": dry_run, + "details": details, + "schematic": str(schematic_path), + } + + def get_datasheet_url(self, lcsc: str) -> Optional[str]: + """ + Return the LCSC datasheet URL for a given LCSC number. + No network request – pure URL construction. + """ + norm = self._normalize_lcsc(lcsc) + if norm: + return LCSC_DATASHEET_URL.format(lcsc=norm) + return None + + def get_product_url(self, lcsc: str) -> Optional[str]: + """Return the LCSC product page URL.""" + norm = self._normalize_lcsc(lcsc) + if norm: + return LCSC_PRODUCT_URL.format(lcsc=norm) + return None diff --git a/python/kicad_interface.py b/python/kicad_interface.py index 2587f61..dcf1f54 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -223,6 +223,7 @@ try: from commands.library_symbol import SymbolLibraryManager, SymbolLibraryCommands from commands.jlcpcb import JLCPCBClient, test_jlcpcb_connection from commands.jlcpcb_parts import JLCPCBPartsManager + from commands.datasheet_manager import DatasheetManager logger.info("Successfully imported all command handlers") except ImportError as e: @@ -357,10 +358,14 @@ class KiCADInterface: "get_jlcpcb_part": self._handle_get_jlcpcb_part, "get_jlcpcb_database_stats": self._handle_get_jlcpcb_database_stats, "suggest_jlcpcb_alternatives": self._handle_suggest_jlcpcb_alternatives, + # Datasheet commands + "enrich_datasheets": self._handle_enrich_datasheets, + "get_datasheet_url": self._handle_get_datasheet_url, # Schematic commands "create_schematic": self._handle_create_schematic, "load_schematic": self._handle_load_schematic, "add_schematic_component": self._handle_add_schematic_component, + "delete_schematic_component": self._handle_delete_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, @@ -606,6 +611,97 @@ class KiCADInterface: logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} + def _handle_delete_schematic_component(self, params): + """Remove a placed symbol from a schematic using text-based manipulation (no skip writes)""" + logger.info("Deleting schematic component") + try: + from pathlib import Path + import re + + schematic_path = params.get("schematicPath") + reference = params.get("reference") + + if not schematic_path: + return {"success": False, "message": "schematicPath is required"} + if not reference: + return {"success": False, "message": "reference is required"} + + 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 it + 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 matching the reference + block_start = None + block_end = None + i = 0 + while i < len(lines): + # Skip lib_symbols + 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 + + # 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 + + i = b_end + 1 + continue + + i += 1 + + if block_start is None: + 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] + + 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)} + + except Exception as e: + logger.error(f"Error deleting 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") @@ -1830,6 +1926,43 @@ class KiCADInterface: "message": f"Failed to suggest alternatives: {str(e)}", } + def _handle_enrich_datasheets(self, params): + """Enrich schematic Datasheet fields from LCSC numbers""" + try: + from pathlib import Path + + schematic_path = params.get("schematic_path") + if not schematic_path: + return {"success": False, "message": "Missing schematic_path parameter"} + dry_run = params.get("dry_run", False) + manager = DatasheetManager() + return manager.enrich_schematic(Path(schematic_path), dry_run=dry_run) + except Exception as e: + logger.error(f"Error enriching datasheets: {e}", exc_info=True) + return {"success": False, "message": f"Failed to enrich datasheets: {str(e)}"} + + def _handle_get_datasheet_url(self, params): + """Return LCSC datasheet and product URLs for a part number""" + try: + lcsc = params.get("lcsc", "") + if not lcsc: + return {"success": False, "message": "Missing lcsc parameter"} + manager = DatasheetManager() + datasheet_url = manager.get_datasheet_url(lcsc) + product_url = manager.get_product_url(lcsc) + if not datasheet_url: + return {"success": False, "message": f"Invalid LCSC number: {lcsc}"} + norm = manager._normalize_lcsc(lcsc) + return { + "success": True, + "lcsc": norm, + "datasheet_url": datasheet_url, + "product_url": product_url, + } + except Exception as e: + logger.error(f"Error getting datasheet URL: {e}", exc_info=True) + return {"success": False, "message": f"Failed to get datasheet URL: {str(e)}"} + def main(): """Main entry point""" diff --git a/src/server.ts b/src/server.ts index 3a83ba5..e2e847d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -21,6 +21,7 @@ import { registerSchematicTools } from "./tools/schematic.js"; import { registerLibraryTools } from "./tools/library.js"; import { registerSymbolLibraryTools } from "./tools/library-symbol.js"; import { registerJLCPCBApiTools } from "./tools/jlcpcb-api.js"; +import { registerDatasheetTools } from "./tools/datasheet.js"; import { registerUITools } from "./tools/ui.js"; import { registerRouterTools } from "./tools/router.js"; @@ -241,6 +242,7 @@ export class KiCADMcpServer { registerLibraryTools(this.server, this.callKicadScript.bind(this)); registerSymbolLibraryTools(this.server, this.callKicadScript.bind(this)); registerJLCPCBApiTools(this.server, this.callKicadScript.bind(this)); + registerDatasheetTools(this.server, this.callKicadScript.bind(this)); registerUITools(this.server, this.callKicadScript.bind(this)); // Register all resources diff --git a/src/tools/datasheet.ts b/src/tools/datasheet.ts new file mode 100644 index 0000000..588b66c --- /dev/null +++ b/src/tools/datasheet.ts @@ -0,0 +1,123 @@ +/** + * Datasheet tools for KiCAD MCP server + * + * Enriches KiCAD schematic symbols with LCSC datasheet URLs. + * URL schema: https://www.lcsc.com/datasheet/.pdf (no API key required) + */ + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; + +export function registerDatasheetTools( + server: McpServer, + callKicadScript: Function, +) { + // ── enrich_datasheets ────────────────────────────────────────────────────── + server.tool( + "enrich_datasheets", + `Fill in missing Datasheet URLs in a KiCAD schematic using LCSC part numbers. + +For every placed symbol that has: + • (property "LCSC" "C123456") set + • (property "Datasheet" "~") or empty + +Sets the Datasheet field to: + https://www.lcsc.com/datasheet/C123456.pdf + +The URL is then visible in KiCAD's footprint browser, symbol properties dialog, +and any tool that reads the standard KiCAD Datasheet field. +No API key or internet lookup required – the URL is constructed directly. + +Use dry_run=true to preview changes without writing.`, + { + schematic_path: z + .string() + .describe("Path to the .kicad_sch file to enrich"), + dry_run: z + .boolean() + .optional() + .default(false) + .describe( + "If true, show what would be changed without writing to disk (default: false)", + ), + }, + async (args: { schematic_path: string; dry_run?: boolean }) => { + const result = await callKicadScript("enrich_datasheets", args); + if (result.success) { + const lines: string[] = []; + + if (args.dry_run) { + lines.push(`[DRY RUN] Schematic: ${result.schematic}\n`); + } else { + lines.push(`Schematic: ${result.schematic}\n`); + } + + lines.push(`✓ Updated: ${result.updated}`); + lines.push(` Already set: ${result.already_set}`); + lines.push(` No LCSC number: ${result.no_lcsc}`); + lines.push(` No field: ${result.no_datasheet_field}`); + + if (result.details && result.details.length > 0) { + lines.push("\nComponents updated:"); + for (const d of result.details) { + lines.push(` ${d.reference.padEnd(6)} ${d.lcsc.padEnd(12)} → ${d.url}`); + } + } + + if (result.updated === 0 && !args.dry_run) { + lines.push( + "\nNo changes needed – all LCSC components already have a Datasheet URL.", + ); + } + + return { content: [{ type: "text", text: lines.join("\n") }] }; + } + return { + content: [ + { + type: "text", + text: `Failed to enrich datasheets: ${result.message || "Unknown error"}`, + }, + ], + }; + }, + ); + + // ── get_datasheet_url ────────────────────────────────────────────────────── + server.tool( + "get_datasheet_url", + `Get the LCSC datasheet URL for a component by LCSC number. + +Returns the direct PDF URL and product page URL. +No network request – URL is constructed from the LCSC number alone. + +Example: get_datasheet_url("C179739") +→ https://www.lcsc.com/datasheet/C179739.pdf`, + { + lcsc: z + .string() + .describe( + 'LCSC part number, with or without "C" prefix (e.g. "C179739" or "179739")', + ), + }, + async (args: { lcsc: string }) => { + const result = await callKicadScript("get_datasheet_url", { lcsc: args.lcsc }); + if (result.success) { + const lines = [ + `LCSC: ${result.lcsc}`, + `Datasheet PDF: ${result.datasheet_url}`, + `Product page: ${result.product_url}`, + ]; + return { content: [{ type: "text", text: lines.join("\n") }] }; + } + return { + content: [ + { + type: "text", + text: `Invalid LCSC number: ${args.lcsc}`, + }, + ], + }; + }, + ); +} diff --git a/src/tools/index.ts b/src/tools/index.ts index af7390c..225f964 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -1,15 +1,16 @@ -/** - * Tools index for KiCAD MCP server - * - * Exports all tool registration functions - */ - -export { registerProjectTools } from './project.js'; -export { registerBoardTools } from './board.js'; -export { registerComponentTools } from './component.js'; -export { registerRoutingTools } from './routing.js'; -export { registerDesignRuleTools } from './design-rules.js'; -export { registerExportTools } from './export.js'; -export { registerSchematicTools } from './schematic.js'; -export { registerLibraryTools } from './library.js'; -export { registerUITools } from './ui.js'; +/** + * Tools index for KiCAD MCP server + * + * Exports all tool registration functions + */ + +export { registerProjectTools } from "./project.js"; +export { registerBoardTools } from "./board.js"; +export { registerComponentTools } from "./component.js"; +export { registerRoutingTools } from "./routing.js"; +export { registerDesignRuleTools } from "./design-rules.js"; +export { registerExportTools } from "./export.js"; +export { registerSchematicTools } from "./schematic.js"; +export { registerLibraryTools } from "./library.js"; +export { registerUITools } from "./ui.js"; +export { registerDatasheetTools } from "./datasheet.js"; diff --git a/src/tools/schematic.ts b/src/tools/schematic.ts index 6e07758..23f9ff1 100644 --- a/src/tools/schematic.ts +++ b/src/tools/schematic.ts @@ -1,268 +1,383 @@ -/** - * Schematic tools for KiCAD MCP server - */ - -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { z } from 'zod'; - -export function registerSchematicTools(server: McpServer, callKicadScript: Function) { - // Create schematic tool - server.tool( - "create_schematic", - "Create a new schematic", - { - name: z.string().describe("Schematic name"), - path: z.string().optional().describe("Optional path"), - }, - async (args: { name: string; path?: string }) => { - const result = await callKicadScript("create_schematic", args); - return { - content: [{ - type: "text", - text: JSON.stringify(result, null, 2) - }] - }; - } - ); - - // Add component to schematic - server.tool( - "add_schematic_component", - "Add a component to the schematic. Symbol format is 'Library:SymbolName' (e.g., 'Device:R', 'EDA-MCP:ESP32-C3')", - { - schematicPath: z.string().describe("Path to the schematic file"), - symbol: z.string().describe("Symbol library:name reference (e.g., Device:R, EDA-MCP:ESP32-C3)"), - reference: z.string().describe("Component reference (e.g., R1, U1)"), - value: z.string().optional().describe("Component value"), - position: z.object({ - x: z.number(), - y: z.number() - }).optional().describe("Position on schematic"), - }, - async (args: { schematicPath: string; symbol: string; reference: string; value?: string; position?: { x: number; y: number } }) => { - // Transform to what Python backend expects - const [library, symbolName] = args.symbol.includes(':') - ? args.symbol.split(':') - : ['Device', args.symbol]; - - const transformed = { - schematicPath: args.schematicPath, - component: { - library, - type: symbolName, - reference: args.reference, - value: args.value, - // Python expects flat x, y not nested position - x: args.position?.x ?? 0, - y: args.position?.y ?? 0 - } - }; - - const result = await callKicadScript("add_schematic_component", transformed); - if (result.success) { - return { - content: [{ - type: "text", - text: `Successfully added ${args.reference} (${args.symbol}) to schematic` - }] - }; - } else { - return { - content: [{ - type: "text", - text: `Failed to add component: ${result.message || JSON.stringify(result)}` - }] - }; - } - } - ); - - // Connect components with wire - server.tool( - "add_wire", - "Add a wire connection in the schematic", - { - start: z.object({ - x: z.number(), - y: z.number() - }).describe("Start position"), - end: z.object({ - x: z.number(), - y: z.number() - }).describe("End position"), - }, - async (args: any) => { - const result = await callKicadScript("add_wire", args); - return { - content: [{ - type: "text", - text: JSON.stringify(result, null, 2) - }] - }; - } - ); - - // Add pin-to-pin connection - server.tool( - "add_schematic_connection", - "Connect two component pins with a wire", - { - schematicPath: z.string().describe("Path to the schematic file"), - sourceRef: z.string().describe("Source component reference (e.g., R1)"), - sourcePin: z.string().describe("Source pin name/number (e.g., 1, 2, GND)"), - targetRef: z.string().describe("Target component reference (e.g., C1)"), - targetPin: z.string().describe("Target pin name/number (e.g., 1, 2, VCC)") - }, - async (args: { schematicPath: string; sourceRef: string; sourcePin: string; targetRef: string; targetPin: string }) => { - const result = await callKicadScript("add_schematic_connection", args); - if (result.success) { - return { - content: [{ - type: "text", - text: `Successfully connected ${args.sourceRef}/${args.sourcePin} to ${args.targetRef}/${args.targetPin}` - }] - }; - } else { - return { - content: [{ - type: "text", - text: `Failed to add connection: ${result.message || 'Unknown error'}` - }] - }; - } - } - ); - - // Add net label - server.tool( - "add_schematic_net_label", - "Add a net label to the schematic", - { - schematicPath: z.string().describe("Path to the schematic file"), - netName: z.string().describe("Name of the net (e.g., VCC, GND, SIGNAL_1)"), - position: z.array(z.number()).length(2).describe("Position [x, y] for the label") - }, - async (args: { schematicPath: string; netName: string; position: number[] }) => { - const result = await callKicadScript("add_schematic_net_label", args); - if (result.success) { - return { - content: [{ - type: "text", - text: `Successfully added net label '${args.netName}' at position [${args.position}]` - }] - }; - } else { - return { - content: [{ - type: "text", - text: `Failed to add net label: ${result.message || 'Unknown error'}` - }] - }; - } - } - ); - - // Connect pin to net - server.tool( - "connect_to_net", - "Connect a component pin to a named net", - { - schematicPath: z.string().describe("Path to the schematic file"), - componentRef: z.string().describe("Component reference (e.g., U1, R1)"), - pinName: z.string().describe("Pin name/number to connect"), - netName: z.string().describe("Name of the net to connect to") - }, - async (args: { schematicPath: string; componentRef: string; pinName: string; netName: string }) => { - const result = await callKicadScript("connect_to_net", args); - if (result.success) { - return { - content: [{ - type: "text", - text: `Successfully connected ${args.componentRef}/${args.pinName} to net '${args.netName}'` - }] - }; - } else { - return { - content: [{ - type: "text", - text: `Failed to connect to net: ${result.message || 'Unknown error'}` - }] - }; - } - } - ); - - // Get net connections - server.tool( - "get_net_connections", - "Get all connections for a named net", - { - schematicPath: z.string().describe("Path to the schematic file"), - netName: z.string().describe("Name of the net to query") - }, - async (args: { schematicPath: string; netName: string }) => { - const result = await callKicadScript("get_net_connections", args); - if (result.success && result.connections) { - const connectionList = result.connections.map((conn: any) => - ` - ${conn.component}/${conn.pin}` - ).join('\n'); - return { - content: [{ - type: "text", - text: `Net '${args.netName}' connections:\n${connectionList}` - }] - }; - } else { - return { - content: [{ - type: "text", - text: `Failed to get net connections: ${result.message || 'Unknown error'}` - }] - }; - } - } - ); - - // Generate netlist - server.tool( - "generate_netlist", - "Generate a netlist from the schematic", - { - schematicPath: z.string().describe("Path to the schematic file") - }, - async (args: { schematicPath: string }) => { - const result = await callKicadScript("generate_netlist", args); - if (result.success && result.netlist) { - const netlist = result.netlist; - const output = [ - `=== Netlist for ${args.schematicPath} ===`, - `\nComponents (${netlist.components.length}):`, - ...netlist.components.map((comp: any) => - ` ${comp.reference}: ${comp.value} (${comp.footprint || 'No footprint'})` - ), - `\nNets (${netlist.nets.length}):`, - ...netlist.nets.map((net: any) => { - const connections = net.connections.map((conn: any) => - `${conn.component}/${conn.pin}` - ).join(', '); - return ` ${net.name}: ${connections}`; - }) - ].join('\n'); - - return { - content: [{ - type: "text", - text: output - }] - }; - } else { - return { - content: [{ - type: "text", - text: `Failed to generate netlist: ${result.message || 'Unknown error'}` - }] - }; - } - } - ); -} +/** + * Schematic tools for KiCAD MCP server + */ + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; + +export function registerSchematicTools( + server: McpServer, + callKicadScript: Function, +) { + // Create schematic tool + server.tool( + "create_schematic", + "Create a new schematic", + { + name: z.string().describe("Schematic name"), + path: z.string().optional().describe("Optional path"), + }, + async (args: { name: string; path?: string }) => { + const result = await callKicadScript("create_schematic", args); + return { + content: [ + { + type: "text", + text: JSON.stringify(result, null, 2), + }, + ], + }; + }, + ); + + // Add component to schematic + server.tool( + "add_schematic_component", + "Add a component to the schematic. Symbol format is 'Library:SymbolName' (e.g., 'Device:R', 'EDA-MCP:ESP32-C3')", + { + schematicPath: z.string().describe("Path to the schematic file"), + symbol: z + .string() + .describe( + "Symbol library:name reference (e.g., Device:R, EDA-MCP:ESP32-C3)", + ), + reference: z.string().describe("Component reference (e.g., R1, U1)"), + value: z.string().optional().describe("Component value"), + position: z + .object({ + x: z.number(), + y: z.number(), + }) + .optional() + .describe("Position on schematic"), + }, + async (args: { + schematicPath: string; + symbol: string; + reference: string; + value?: string; + position?: { x: number; y: number }; + }) => { + // Transform to what Python backend expects + const [library, symbolName] = args.symbol.includes(":") + ? args.symbol.split(":") + : ["Device", args.symbol]; + + const transformed = { + schematicPath: args.schematicPath, + component: { + library, + type: symbolName, + reference: args.reference, + value: args.value, + // Python expects flat x, y not nested position + x: args.position?.x ?? 0, + y: args.position?.y ?? 0, + }, + }; + + const result = await callKicadScript( + "add_schematic_component", + transformed, + ); + if (result.success) { + return { + content: [ + { + type: "text", + text: `Successfully added ${args.reference} (${args.symbol}) to schematic`, + }, + ], + }; + } else { + return { + content: [ + { + type: "text", + text: `Failed to add component: ${result.message || JSON.stringify(result)}`, + }, + ], + }; + } + }, + ); + + // Delete component from schematic + server.tool( + "delete_schematic_component", + `Remove a placed symbol from a KiCAD schematic (.kicad_sch). + +This removes the symbol instance (the placed component) from the schematic. +It does NOT remove the symbol definition from lib_symbols. + +Note: This tool operates on schematic files (.kicad_sch). +To remove a footprint from a PCB, use delete_component instead.`, + { + schematicPath: z.string().describe("Path to the .kicad_sch file"), + reference: z + .string() + .describe("Reference designator of the component to remove (e.g. R1, U3)"), + }, + async (args: { schematicPath: string; reference: string }) => { + const result = await callKicadScript("delete_schematic_component", args); + if (result.success) { + return { + content: [ + { + type: "text", + text: `Successfully removed ${args.reference} from schematic`, + }, + ], + }; + } + return { + content: [ + { + type: "text", + text: `Failed to remove component: ${result.message || "Unknown error"}`, + }, + ], + }; + }, + ); + + // Connect components with wire + server.tool( + "add_wire", + "Add a wire connection in the schematic", + { + start: z + .object({ + x: z.number(), + y: z.number(), + }) + .describe("Start position"), + end: z + .object({ + x: z.number(), + y: z.number(), + }) + .describe("End position"), + }, + async (args: any) => { + const result = await callKicadScript("add_wire", args); + return { + content: [ + { + type: "text", + text: JSON.stringify(result, null, 2), + }, + ], + }; + }, + ); + + // Add pin-to-pin connection + server.tool( + "add_schematic_connection", + "Connect two component pins with a wire", + { + schematicPath: z.string().describe("Path to the schematic file"), + sourceRef: z.string().describe("Source component reference (e.g., R1)"), + sourcePin: z + .string() + .describe("Source pin name/number (e.g., 1, 2, GND)"), + targetRef: z.string().describe("Target component reference (e.g., C1)"), + targetPin: z + .string() + .describe("Target pin name/number (e.g., 1, 2, VCC)"), + }, + async (args: { + schematicPath: string; + sourceRef: string; + sourcePin: string; + targetRef: string; + targetPin: string; + }) => { + const result = await callKicadScript("add_schematic_connection", args); + if (result.success) { + return { + content: [ + { + type: "text", + text: `Successfully connected ${args.sourceRef}/${args.sourcePin} to ${args.targetRef}/${args.targetPin}`, + }, + ], + }; + } else { + return { + content: [ + { + type: "text", + text: `Failed to add connection: ${result.message || "Unknown error"}`, + }, + ], + }; + } + }, + ); + + // Add net label + server.tool( + "add_schematic_net_label", + "Add a net label to the schematic", + { + schematicPath: z.string().describe("Path to the schematic file"), + netName: z + .string() + .describe("Name of the net (e.g., VCC, GND, SIGNAL_1)"), + position: z + .array(z.number()) + .length(2) + .describe("Position [x, y] for the label"), + }, + async (args: { + schematicPath: string; + netName: string; + position: number[]; + }) => { + const result = await callKicadScript("add_schematic_net_label", args); + if (result.success) { + return { + content: [ + { + type: "text", + text: `Successfully added net label '${args.netName}' at position [${args.position}]`, + }, + ], + }; + } else { + return { + content: [ + { + type: "text", + text: `Failed to add net label: ${result.message || "Unknown error"}`, + }, + ], + }; + } + }, + ); + + // Connect pin to net + server.tool( + "connect_to_net", + "Connect a component pin to a named net", + { + schematicPath: z.string().describe("Path to the schematic file"), + componentRef: z.string().describe("Component reference (e.g., U1, R1)"), + pinName: z.string().describe("Pin name/number to connect"), + netName: z.string().describe("Name of the net to connect to"), + }, + async (args: { + schematicPath: string; + componentRef: string; + pinName: string; + netName: string; + }) => { + const result = await callKicadScript("connect_to_net", args); + if (result.success) { + return { + content: [ + { + type: "text", + text: `Successfully connected ${args.componentRef}/${args.pinName} to net '${args.netName}'`, + }, + ], + }; + } else { + return { + content: [ + { + type: "text", + text: `Failed to connect to net: ${result.message || "Unknown error"}`, + }, + ], + }; + } + }, + ); + + // Get net connections + server.tool( + "get_net_connections", + "Get all connections for a named net", + { + schematicPath: z.string().describe("Path to the schematic file"), + netName: z.string().describe("Name of the net to query"), + }, + async (args: { schematicPath: string; netName: string }) => { + const result = await callKicadScript("get_net_connections", args); + if (result.success && result.connections) { + const connectionList = result.connections + .map((conn: any) => ` - ${conn.component}/${conn.pin}`) + .join("\n"); + return { + content: [ + { + type: "text", + text: `Net '${args.netName}' connections:\n${connectionList}`, + }, + ], + }; + } else { + return { + content: [ + { + type: "text", + text: `Failed to get net connections: ${result.message || "Unknown error"}`, + }, + ], + }; + } + }, + ); + + // Generate netlist + server.tool( + "generate_netlist", + "Generate a netlist from the schematic", + { + schematicPath: z.string().describe("Path to the schematic file"), + }, + async (args: { schematicPath: string }) => { + const result = await callKicadScript("generate_netlist", args); + if (result.success && result.netlist) { + const netlist = result.netlist; + const output = [ + `=== Netlist for ${args.schematicPath} ===`, + `\nComponents (${netlist.components.length}):`, + ...netlist.components.map( + (comp: any) => + ` ${comp.reference}: ${comp.value} (${comp.footprint || "No footprint"})`, + ), + `\nNets (${netlist.nets.length}):`, + ...netlist.nets.map((net: any) => { + const connections = net.connections + .map((conn: any) => `${conn.component}/${conn.pin}`) + .join(", "); + return ` ${net.name}: ${connections}`; + }), + ].join("\n"); + + return { + content: [ + { + type: "text", + text: output, + }, + ], + }; + } else { + return { + content: [ + { + type: "text", + text: `Failed to generate netlist: ${result.message || "Unknown error"}`, + }, + ], + }; + } + }, + ); +}