Rebased onto current main (d765bfe). Merged changes:
Board view (kicad-cli, cffi-free):
- Replace pcbnew/PLOT_CONTROLLER + cairosvg with kicad-cli SVG export
and cffi-free PNG conversion: pymupdf → inkscape → imagemagick chain
- pcbPath optional param with fallback to loaded board
- Restore jpg output via PIL post-processing on PNG bytes
- responseMode inline/file from main's #161: inline returns imageData,
file writes <board>_2d_view.<ext> and returns filePath
- shutil.which for cross-platform kicad-cli lookup
- Explicit TimeoutExpired catch; errorDetails on all error returns
- Validate fmt strictly; TypeScript returns image type for inline png/jpg
Schematic view (kicad_interface.py):
- Same cffi-free _svg_to_png helper for get_schematic_view and
get_schematic_view_region (pymupdf → inkscape → imagemagick)
Tests:
- Update test_get_board_2d_view_save_to_file.py to mock subprocess/
kicad-cli and _svg_to_png instead of PLOT_CONTROLLER/cairosvg
- All 5 responseMode tests pass
493 lines
16 KiB
TypeScript
493 lines
16 KiB
TypeScript
/**
|
||
* Board management tools for KiCAD MCP server
|
||
*
|
||
* These tools handle board setup, layer management, and board properties
|
||
*/
|
||
|
||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||
import { z } from "zod";
|
||
import { logger } from "../logger.js";
|
||
|
||
// Command function type for KiCAD script calls
|
||
type CommandFunction = (command: string, params: Record<string, unknown>) => Promise<any>;
|
||
|
||
/**
|
||
* Register board management tools with the MCP server
|
||
*
|
||
* @param server MCP server instance
|
||
* @param callKicadScript Function to call KiCAD script commands
|
||
*/
|
||
export function registerBoardTools(server: McpServer, callKicadScript: CommandFunction): void {
|
||
logger.info("Registering board management tools");
|
||
|
||
// ------------------------------------------------------
|
||
// Set Board Size Tool
|
||
// ------------------------------------------------------
|
||
server.tool(
|
||
"set_board_size",
|
||
"Set the PCB board dimensions (width and height) in the specified unit.",
|
||
{
|
||
width: z.number().describe("Board width"),
|
||
height: z.number().describe("Board height"),
|
||
unit: z.enum(["mm", "mil", "inch"]).describe("Unit of measurement"),
|
||
},
|
||
async ({ width, height, unit }) => {
|
||
logger.debug(`Setting board size to ${width}x${height} ${unit}`);
|
||
const result = await callKicadScript("set_board_size", {
|
||
width,
|
||
height,
|
||
unit,
|
||
});
|
||
|
||
return {
|
||
content: [
|
||
{
|
||
type: "text",
|
||
text: JSON.stringify(result),
|
||
},
|
||
],
|
||
};
|
||
},
|
||
);
|
||
|
||
// ------------------------------------------------------
|
||
// Add Layer Tool
|
||
// ------------------------------------------------------
|
||
server.tool(
|
||
"add_layer",
|
||
"Add a new copper or technical layer to the PCB stackup.",
|
||
{
|
||
name: z.string().describe("Layer name"),
|
||
type: z.enum(["copper", "technical", "user", "signal"]).describe("Layer type"),
|
||
position: z.enum(["top", "bottom", "inner"]).describe("Layer position"),
|
||
number: z.number().optional().describe("Layer number (for inner layers)"),
|
||
},
|
||
async ({ name, type, position, number }) => {
|
||
logger.debug(`Adding ${type} layer: ${name}`);
|
||
const result = await callKicadScript("add_layer", {
|
||
name,
|
||
type,
|
||
position,
|
||
number,
|
||
});
|
||
|
||
return {
|
||
content: [
|
||
{
|
||
type: "text",
|
||
text: JSON.stringify(result),
|
||
},
|
||
],
|
||
};
|
||
},
|
||
);
|
||
|
||
// ------------------------------------------------------
|
||
// Set Active Layer Tool
|
||
// ------------------------------------------------------
|
||
server.tool(
|
||
"set_active_layer",
|
||
"Set the currently active PCB layer by name (e.g. F.Cu, B.Cu).",
|
||
{
|
||
layer: z.string().describe("Layer name to set as active"),
|
||
},
|
||
async ({ layer }) => {
|
||
logger.debug(`Setting active layer to: ${layer}`);
|
||
const result = await callKicadScript("set_active_layer", { layer });
|
||
|
||
return {
|
||
content: [
|
||
{
|
||
type: "text",
|
||
text: JSON.stringify(result),
|
||
},
|
||
],
|
||
};
|
||
},
|
||
);
|
||
|
||
// ------------------------------------------------------
|
||
// Get Board Info Tool
|
||
// ------------------------------------------------------
|
||
server.tool(
|
||
"get_board_info",
|
||
"Retrieve general information about the current PCB board (dimensions, layer count, DRC status).",
|
||
{},
|
||
async () => {
|
||
logger.debug("Getting board information");
|
||
const result = await callKicadScript("get_board_info", {});
|
||
|
||
return {
|
||
content: [
|
||
{
|
||
type: "text",
|
||
text: JSON.stringify(result),
|
||
},
|
||
],
|
||
};
|
||
},
|
||
);
|
||
|
||
// ------------------------------------------------------
|
||
// Get Layer List Tool
|
||
// ------------------------------------------------------
|
||
server.tool(
|
||
"get_layer_list",
|
||
"Return the list of all layers defined in the current PCB board.",
|
||
{},
|
||
async () => {
|
||
logger.debug("Getting layer list");
|
||
const result = await callKicadScript("get_layer_list", {});
|
||
|
||
return {
|
||
content: [
|
||
{
|
||
type: "text",
|
||
text: JSON.stringify(result),
|
||
},
|
||
],
|
||
};
|
||
},
|
||
);
|
||
|
||
// ------------------------------------------------------
|
||
// Add Board Outline Tool
|
||
// ------------------------------------------------------
|
||
server.tool(
|
||
"add_board_outline",
|
||
"Draw the PCB board outline (Edge.Cuts layer) as a rectangle, rounded rectangle, circle or polygon.",
|
||
{
|
||
shape: z
|
||
.enum(["rectangle", "circle", "polygon", "rounded_rectangle"])
|
||
.describe("Shape of the outline"),
|
||
params: z
|
||
.object({
|
||
// For rectangle / rounded_rectangle
|
||
width: z.number().optional().describe("Width of rectangle"),
|
||
height: z.number().optional().describe("Height of rectangle"),
|
||
cornerRadius: z.number().optional().describe("Corner radius for rounded_rectangle (mm)"),
|
||
// For circle
|
||
radius: z.number().optional().describe("Radius of circle"),
|
||
// For polygon
|
||
points: z
|
||
.array(
|
||
z.object({
|
||
x: z.number().describe("X coordinate"),
|
||
y: z.number().describe("Y coordinate"),
|
||
}),
|
||
)
|
||
.optional()
|
||
.describe("Points of polygon"),
|
||
// Position: top-left corner for rectangles/rounded_rectangle, center for circle
|
||
x: z.number().describe("X coordinate of top-left corner for rectangles (default: 0)"),
|
||
y: z.number().describe("Y coordinate of top-left corner for rectangles (default: 0)"),
|
||
unit: z.enum(["mm", "mil", "inch"]).describe("Unit of measurement"),
|
||
})
|
||
.describe("Parameters for the outline shape"),
|
||
},
|
||
async ({ shape, params }) => {
|
||
logger.debug(`Adding ${shape} board outline`);
|
||
// Pass x/y as-is to Python; outline.py treats them as top-left corner
|
||
// and computes the center internally (center = x + width/2, y + height/2).
|
||
const result = await callKicadScript("add_board_outline", {
|
||
shape,
|
||
...params,
|
||
});
|
||
|
||
return {
|
||
content: [
|
||
{
|
||
type: "text",
|
||
text: JSON.stringify(result),
|
||
},
|
||
],
|
||
};
|
||
},
|
||
);
|
||
|
||
// ------------------------------------------------------
|
||
// Add Mounting Hole Tool
|
||
// ------------------------------------------------------
|
||
server.tool(
|
||
"add_mounting_hole",
|
||
"Place a mounting hole (NPTH or PTH) at the specified position on the PCB.",
|
||
{
|
||
position: z
|
||
.object({
|
||
x: z.number().describe("X coordinate"),
|
||
y: z.number().describe("Y coordinate"),
|
||
unit: z.enum(["mm", "mil", "inch"]).describe("Unit of measurement"),
|
||
})
|
||
.describe("Position of the mounting hole"),
|
||
diameter: z.number().describe("Diameter of the hole"),
|
||
padDiameter: z.number().optional().describe("Optional diameter of the pad around the hole"),
|
||
},
|
||
async ({ position, diameter, padDiameter }) => {
|
||
logger.debug(`Adding mounting hole at (${position.x},${position.y}) ${position.unit}`);
|
||
const result = await callKicadScript("add_mounting_hole", {
|
||
position,
|
||
diameter,
|
||
padDiameter,
|
||
});
|
||
|
||
return {
|
||
content: [
|
||
{
|
||
type: "text",
|
||
text: JSON.stringify(result),
|
||
},
|
||
],
|
||
};
|
||
},
|
||
);
|
||
|
||
// ------------------------------------------------------
|
||
// Add Text Tool
|
||
// ------------------------------------------------------
|
||
server.tool(
|
||
"add_board_text",
|
||
"Add a text label to a PCB layer (e.g. silkscreen, fab, courtyard).",
|
||
{
|
||
text: z.string().describe("Text content"),
|
||
position: z
|
||
.object({
|
||
x: z.number().describe("X coordinate"),
|
||
y: z.number().describe("Y coordinate"),
|
||
unit: z.enum(["mm", "mil", "inch"]).describe("Unit of measurement"),
|
||
})
|
||
.describe("Position of the text"),
|
||
layer: z.string().describe("Layer to place the text on"),
|
||
size: z.number().describe("Text size"),
|
||
thickness: z.number().optional().describe("Line thickness"),
|
||
rotation: z.number().optional().describe("Rotation angle in degrees"),
|
||
style: z.enum(["normal", "italic", "bold"]).optional().describe("Text style"),
|
||
},
|
||
async ({ text, position, layer, size, thickness, rotation, style }) => {
|
||
logger.debug(`Adding text "${text}" at (${position.x},${position.y}) ${position.unit}`);
|
||
const result = await callKicadScript("add_board_text", {
|
||
text,
|
||
position,
|
||
layer,
|
||
size,
|
||
thickness,
|
||
rotation,
|
||
style,
|
||
});
|
||
|
||
return {
|
||
content: [
|
||
{
|
||
type: "text",
|
||
text: JSON.stringify(result),
|
||
},
|
||
],
|
||
};
|
||
},
|
||
);
|
||
|
||
// ------------------------------------------------------
|
||
// Add Zone Tool
|
||
// ------------------------------------------------------
|
||
server.tool(
|
||
"add_zone",
|
||
"Create a copper fill zone (pour) on a PCB layer for a specified net.",
|
||
{
|
||
layer: z.string().describe("Layer for the zone"),
|
||
net: z.string().describe("Net name for the zone"),
|
||
points: z
|
||
.array(
|
||
z.object({
|
||
x: z.number().describe("X coordinate"),
|
||
y: z.number().describe("Y coordinate"),
|
||
}),
|
||
)
|
||
.describe("Points defining the zone outline"),
|
||
unit: z.enum(["mm", "mil", "inch"]).describe("Unit of measurement"),
|
||
clearance: z.number().optional().describe("Clearance value"),
|
||
minWidth: z.number().optional().describe("Minimum width"),
|
||
padConnection: z
|
||
.enum(["thermal", "solid", "none"])
|
||
.optional()
|
||
.describe("Pad connection type"),
|
||
},
|
||
async ({ layer, net, points, unit, clearance, minWidth, padConnection }) => {
|
||
logger.debug(`Adding zone on layer ${layer} for net ${net}`);
|
||
const result = await callKicadScript("add_zone", {
|
||
layer,
|
||
net,
|
||
points,
|
||
unit,
|
||
clearance,
|
||
minWidth,
|
||
padConnection,
|
||
});
|
||
|
||
return {
|
||
content: [
|
||
{
|
||
type: "text",
|
||
text: JSON.stringify(result),
|
||
},
|
||
],
|
||
};
|
||
},
|
||
);
|
||
|
||
// ------------------------------------------------------
|
||
// Get Board Extents Tool
|
||
// ------------------------------------------------------
|
||
server.tool(
|
||
"get_board_extents",
|
||
"Return the bounding box (min/max X and Y) of all objects on the current PCB board.",
|
||
{
|
||
unit: z.enum(["mm", "mil", "inch"]).optional().describe("Unit of measurement for the result"),
|
||
},
|
||
async ({ unit }) => {
|
||
logger.debug("Getting board extents");
|
||
const result = await callKicadScript("get_board_extents", { unit });
|
||
|
||
return {
|
||
content: [
|
||
{
|
||
type: "text",
|
||
text: JSON.stringify(result),
|
||
},
|
||
],
|
||
};
|
||
},
|
||
);
|
||
|
||
// ------------------------------------------------------
|
||
// Get Board 2D View Tool
|
||
// ------------------------------------------------------
|
||
server.tool(
|
||
"get_board_2d_view",
|
||
[
|
||
"Render a 2D image of the PCB using kicad-cli. Returns PNG, JPG, or SVG.",
|
||
"Use layers to filter — e.g. [\"F.Cu\",\"B.Cu\",\"Edge.Cuts\"] for copper + outline only.",
|
||
"Use responseMode to choose delivery:",
|
||
' "inline" (default) — PNG/JPG rendered as an image visible to Claude; SVG returned as text.',
|
||
' "file" — image written next to the .kicad_pcb as <board>_2d_view.<ext>; filePath is returned.',
|
||
"Use file mode for large boards to avoid MCP message-size limits.",
|
||
].join(" "),
|
||
{
|
||
pcbPath: z.string().optional().describe("Absolute path to the .kicad_pcb file. Falls back to the currently loaded board if omitted."),
|
||
layers: z.array(z.string()).optional().describe("Layer names to include, e.g. [\"F.Cu\",\"B.Cu\",\"Edge.Cuts\"]. Omit for all layers."),
|
||
width: z.number().optional().describe("Output image width in pixels (default: 1600)"),
|
||
height: z.number().optional().describe("Output image height in pixels (default: 1200)"),
|
||
format: z.enum(["png", "jpg", "svg"]).optional().describe("Output format (default: png)"),
|
||
responseMode: z
|
||
.enum(["inline", "file"])
|
||
.optional()
|
||
.describe(
|
||
'"inline" (default): image returned directly; "file": written to disk, filePath returned',
|
||
),
|
||
},
|
||
async ({ pcbPath, layers, width, height, format, responseMode }) => {
|
||
logger.debug("Getting 2D board view");
|
||
const result = await callKicadScript("get_board_2d_view", {
|
||
pcbPath,
|
||
layers,
|
||
width,
|
||
height,
|
||
format,
|
||
responseMode,
|
||
});
|
||
|
||
if (result.success) {
|
||
// file mode — just return the path as text
|
||
if (responseMode === "file" || result.filePath) {
|
||
return {
|
||
content: [{ type: "text" as const, text: result.message || result.filePath }],
|
||
};
|
||
}
|
||
// inline svg (or fallback svg) — return as text, prepend any notice
|
||
if (result.format === "svg") {
|
||
const parts: { type: "text"; text: string }[] = [];
|
||
if (result.message) parts.push({ type: "text" as const, text: result.message });
|
||
parts.push({ type: "text" as const, text: Buffer.from(result.imageData, "base64").toString("utf-8") });
|
||
return { content: parts };
|
||
}
|
||
// inline png/jpg — return as renderable image
|
||
return {
|
||
content: [
|
||
{
|
||
type: "image" as const,
|
||
data: result.imageData,
|
||
mimeType: result.format === "jpg" ? "image/jpeg" : "image/png",
|
||
},
|
||
],
|
||
};
|
||
}
|
||
return {
|
||
content: [
|
||
{
|
||
type: "text" as const,
|
||
text: `Failed to get board view: ${result.message || result.errorDetails || "Unknown error"}`,
|
||
},
|
||
],
|
||
isError: true,
|
||
};
|
||
},
|
||
);
|
||
|
||
logger.info("Board management tools registered");
|
||
|
||
// Import SVG logo onto PCB layer (silkscreen)
|
||
server.tool(
|
||
"import_svg_logo",
|
||
"Imports an SVG file as filled graphic polygons onto a KiCAD PCB layer (default F.SilkS / front silkscreen). Curves are linearised automatically. Ideal for placing a company or project logo on the board.",
|
||
{
|
||
pcbPath: z.string().describe("Path to the .kicad_pcb file"),
|
||
svgPath: z.string().describe("Path to the SVG logo file"),
|
||
x: z.number().describe("X position of the logo top-left corner in mm"),
|
||
y: z.number().describe("Y position of the logo top-left corner in mm"),
|
||
width: z
|
||
.number()
|
||
.describe("Target width of the logo in mm (height is scaled to preserve aspect ratio)"),
|
||
layer: z
|
||
.string()
|
||
.optional()
|
||
.describe("PCB layer name, e.g. F.SilkS or B.SilkS (default: F.SilkS)"),
|
||
strokeWidth: z
|
||
.number()
|
||
.optional()
|
||
.describe("Outline stroke width in mm (0 = no outline, default 0)"),
|
||
filled: z.boolean().optional().describe("Fill polygons with solid colour (default true)"),
|
||
},
|
||
async (args: {
|
||
pcbPath: string;
|
||
svgPath: string;
|
||
x: number;
|
||
y: number;
|
||
width: number;
|
||
layer?: string;
|
||
strokeWidth?: number;
|
||
filled?: boolean;
|
||
}) => {
|
||
const result = await callKicadScript("import_svg_logo", args);
|
||
if (result.success) {
|
||
return {
|
||
content: [
|
||
{
|
||
type: "text",
|
||
text: [
|
||
result.message,
|
||
`Polygons: ${result.polygon_count}`,
|
||
`Size: ${result.logo_width_mm?.toFixed(2)} × ${result.logo_height_mm?.toFixed(2)} mm`,
|
||
`Layer: ${result.layer}`,
|
||
].join("\n"),
|
||
},
|
||
],
|
||
};
|
||
} else {
|
||
return {
|
||
content: [
|
||
{ type: "text", text: `SVG import failed: ${result.message || "Unknown error"}` },
|
||
],
|
||
};
|
||
}
|
||
},
|
||
);
|
||
}
|