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/<C#>.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
This commit is contained in:
Tom
2026-02-28 13:48:35 +01:00
parent 76503b144c
commit 1ba86f7769
7 changed files with 985 additions and 283 deletions

View File

@@ -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

View File

@@ -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": <count>,
"already_set": <count>,
"no_lcsc": <count>,
"no_datasheet_field": <count>,
"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

View File

@@ -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"""

View File

@@ -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

123
src/tools/datasheet.ts Normal file
View File

@@ -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/<LCSC#>.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}`,
},
],
};
},
);
}

View File

@@ -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";

View File

@@ -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"}`,
},
],
};
}
},
);
}