Adds add_hierarchical_sheet (insert a sheet symbol referencing a child .kicad_sch, with sheet_instances + fixed component instance paths) and create_hierarchical_subsheet (create the child file + wire it into the parent in one call). Upstream has add_sheet_pin / add_schematic_hierarchical_label but no way to create a sheet or stand up a child sheet, so hierarchical designs can't be built through the MCP server today. - python/commands/schematic_hierarchy.py: SchematicHierarchyCommands - src/tools/schematic-hierarchy.ts + registry 'schematic_hierarchy' category - python/kicad_interface.py: import + instantiate + dispatch routes - tests/test_schematic_hierarchy.py: 5 unit/integration tests on real .kicad_sch Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
940 lines
34 KiB
TypeScript
940 lines
34 KiB
TypeScript
/**
|
|
* KiCAD MCP Server implementation
|
|
*/
|
|
|
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
import express from "express";
|
|
import { spawn, exec, execSync, ChildProcess } from "child_process";
|
|
import { existsSync, readdirSync } from "fs";
|
|
import { join, dirname } from "path";
|
|
import { logger } from "./logger.js";
|
|
|
|
// Import tool registration functions
|
|
import { registerProjectTools } from "./tools/project.js";
|
|
import { registerBoardTools } from "./tools/board.js";
|
|
import { registerComponentTools } from "./tools/component.js";
|
|
import { registerRoutingTools } from "./tools/routing.js";
|
|
import { registerDesignRuleTools } from "./tools/design-rules.js";
|
|
import { registerExportTools } from "./tools/export.js";
|
|
import { registerSchematicTools } from "./tools/schematic.js";
|
|
import { registerLibraryTools } from "./tools/library.js";
|
|
import { registerSymbolLibraryTools } from "./tools/library-symbol.js";
|
|
import { registerSchematicHierarchyTools } from "./tools/schematic-hierarchy.js";
|
|
import { registerJLCPCBApiTools } from "./tools/jlcpcb-api.js";
|
|
import { registerDatasheetTools } from "./tools/datasheet.js";
|
|
import { registerFootprintTools } from "./tools/footprint.js";
|
|
import { registerSymbolCreatorTools } from "./tools/symbol-creator.js";
|
|
import { registerUITools } from "./tools/ui.js";
|
|
import { registerFreeroutingTools } from "./tools/freerouting.js";
|
|
import { registerRouterTools } from "./tools/router.js";
|
|
|
|
// Import resource registration functions
|
|
import { registerProjectResources } from "./resources/project.js";
|
|
import { registerBoardResources } from "./resources/board.js";
|
|
import { registerComponentResources } from "./resources/component.js";
|
|
import { registerLibraryResources } from "./resources/library.js";
|
|
|
|
// Import prompt registration functions
|
|
import { registerComponentPrompts } from "./prompts/component.js";
|
|
import { registerRoutingPrompts } from "./prompts/routing.js";
|
|
import { registerDesignPrompts } from "./prompts/design.js";
|
|
import { registerFootprintPrompts } from "./prompts/footprint.js";
|
|
|
|
function getWindowsKiCadPythonCandidates(): string[] {
|
|
const roots = [
|
|
process.env.LOCALAPPDATA ? join(process.env.LOCALAPPDATA, "Programs", "KiCad") : undefined,
|
|
"C:\\Program Files\\KiCad",
|
|
"C:\\Program Files (x86)\\KiCad",
|
|
].filter((root): root is string => Boolean(root));
|
|
|
|
const candidates: string[] = [];
|
|
|
|
for (const root of roots) {
|
|
if (!existsSync(root)) {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const versionDirs = readdirSync(root, { withFileTypes: true })
|
|
.filter((entry) => entry.isDirectory())
|
|
.map((entry) => entry.name)
|
|
.sort((a, b) => b.localeCompare(a, undefined, { numeric: true }));
|
|
|
|
for (const versionDir of versionDirs) {
|
|
candidates.push(join(root, versionDir, "bin", "python.exe"));
|
|
}
|
|
} catch (error: any) {
|
|
logger.warn(`Failed to inspect KiCAD install directory ${root}: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
return [...new Set(candidates)];
|
|
}
|
|
|
|
/**
|
|
* Derive the KiCAD bundled-Python site-packages path for a detected python.exe,
|
|
* so PYTHONPATH follows the *same* install we picked (any version, Program Files
|
|
* or per-user %LOCALAPPDATA%) instead of a hardcoded KiCad 9.0 path.
|
|
*
|
|
* KiCAD on Windows installs python at `<root>/<version>/bin/python.exe`, with
|
|
* pcbnew under `<...>/bin/Lib/site-packages` (older/alt layouts use
|
|
* `<version>/lib/python3/dist-packages`). Returns the first existing candidate,
|
|
* or undefined if pythonExe isn't a KiCAD bundled python.
|
|
*/
|
|
function deriveKiCadSitePackages(pythonExe: string): string | undefined {
|
|
if (process.platform !== "win32") return undefined;
|
|
const lower = pythonExe.toLowerCase();
|
|
if (!lower.endsWith("python.exe") || !lower.includes("kicad")) return undefined;
|
|
const binDir = dirname(pythonExe); // <root>/<version>/bin
|
|
const versionDir = dirname(binDir); // <root>/<version>
|
|
const candidates = [
|
|
join(binDir, "Lib", "site-packages"),
|
|
join(versionDir, "lib", "python3", "dist-packages"),
|
|
];
|
|
return candidates.find((p) => existsSync(p));
|
|
}
|
|
|
|
/**
|
|
* Find the Python executable to use.
|
|
* Prioritizes project venvs, then explicit overrides, then KiCAD-bundled Python
|
|
* before falling back to system Python.
|
|
*/
|
|
function findPythonExecutable(scriptPath: string): string {
|
|
const isWindows = process.platform === "win32";
|
|
const isMac = process.platform === "darwin";
|
|
const isLinux = !isWindows && !isMac;
|
|
|
|
// Get the project root (parent of the python/ directory)
|
|
const projectRoot = dirname(dirname(scriptPath));
|
|
|
|
// Check for virtual environment
|
|
const venvPaths = [
|
|
join(projectRoot, "venv", isWindows ? "Scripts" : "bin", isWindows ? "python.exe" : "python"),
|
|
join(projectRoot, ".venv", isWindows ? "Scripts" : "bin", isWindows ? "python.exe" : "python"),
|
|
];
|
|
|
|
for (const venvPath of venvPaths) {
|
|
if (existsSync(venvPath)) {
|
|
logger.info(`Found virtual environment Python at: ${venvPath}`);
|
|
return venvPath;
|
|
}
|
|
}
|
|
|
|
// Allow override via KICAD_PYTHON environment variable (any platform)
|
|
if (process.env.KICAD_PYTHON) {
|
|
logger.info(`Using KICAD_PYTHON environment variable: ${process.env.KICAD_PYTHON}`);
|
|
return process.env.KICAD_PYTHON;
|
|
}
|
|
|
|
// Platform-specific KiCAD bundled Python detection
|
|
if (isWindows) {
|
|
// Windows: Always prefer KiCAD's bundled Python (pcbnew.pyd is compiled for it).
|
|
for (const kicadPython of getWindowsKiCadPythonCandidates()) {
|
|
if (existsSync(kicadPython)) {
|
|
logger.info(`Found KiCAD bundled Python at: ${kicadPython}`);
|
|
return kicadPython;
|
|
}
|
|
}
|
|
} else if (isMac) {
|
|
// macOS: Try KiCAD's bundled Python (check multiple versions and locations)
|
|
const kicadPythonVersions = ["3.9", "3.10", "3.11", "3.12", "3.13"];
|
|
|
|
// Standard KiCAD installation paths
|
|
const kicadAppPaths = [
|
|
"/Applications/KiCad/KiCad.app",
|
|
"/Applications/KiCAD/KiCad.app", // Alternative capitalization
|
|
`${process.env.HOME}/Applications/KiCad/KiCad.app`, // User Applications folder
|
|
];
|
|
|
|
// Check all KiCAD app locations with all Python versions
|
|
for (const appPath of kicadAppPaths) {
|
|
for (const version of kicadPythonVersions) {
|
|
const kicadPython = `${appPath}/Contents/Frameworks/Python.framework/Versions/${version}/bin/python3`;
|
|
if (existsSync(kicadPython)) {
|
|
logger.info(`Found KiCAD bundled Python at: ${kicadPython}`);
|
|
return kicadPython;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fallback to Homebrew Python (if pcbnew is installed via pip)
|
|
const homebrewPaths = [
|
|
"/opt/homebrew/bin/python3", // Apple Silicon
|
|
"/usr/local/bin/python3", // Intel Mac
|
|
"/opt/homebrew/bin/python3.12",
|
|
"/opt/homebrew/bin/python3.11",
|
|
];
|
|
|
|
for (const path of homebrewPaths) {
|
|
if (existsSync(path)) {
|
|
logger.info(`Found Homebrew Python at: ${path} (ensure pcbnew is importable)`);
|
|
return path;
|
|
}
|
|
}
|
|
} else if (isLinux) {
|
|
// Linux: Try KiCAD bundled Python locations first
|
|
const linuxKicadPaths = [
|
|
"/usr/lib/kicad/bin/python3",
|
|
"/usr/local/lib/kicad/bin/python3",
|
|
"/opt/kicad/bin/python3",
|
|
];
|
|
|
|
for (const path of linuxKicadPaths) {
|
|
if (existsSync(path)) {
|
|
logger.info(`Found KiCAD bundled Python at: ${path}`);
|
|
return path;
|
|
}
|
|
}
|
|
|
|
// Resolve system python3 to full path using 'which'
|
|
try {
|
|
const result = execSync("which python3", { encoding: "utf-8" }).trim();
|
|
if (result && existsSync(result)) {
|
|
logger.info(`Resolved system Python via which: ${result}`);
|
|
return result;
|
|
}
|
|
} catch (e) {
|
|
logger.warn("Failed to resolve python3 via which command");
|
|
}
|
|
|
|
// Fallback to common system paths
|
|
const systemPaths = ["/usr/bin/python3", "/bin/python3"];
|
|
for (const path of systemPaths) {
|
|
if (existsSync(path)) {
|
|
logger.info(`Found system Python at: ${path}`);
|
|
return path;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Default to system Python (last resort)
|
|
logger.info("Using system Python (no venv found)");
|
|
return isWindows ? "python.exe" : "python3";
|
|
}
|
|
|
|
/**
|
|
* KiCAD MCP Server class
|
|
*/
|
|
export class KiCADMcpServer {
|
|
private server: McpServer;
|
|
private pythonProcess: ChildProcess | null = null;
|
|
private kicadScriptPath: string;
|
|
private stdioTransport!: StdioServerTransport;
|
|
private requestQueue: Array<{
|
|
request: any;
|
|
resolve: Function;
|
|
reject: Function;
|
|
}> = [];
|
|
private processingRequest = false;
|
|
private responseBuffer: string = "";
|
|
private currentRequestHandler: {
|
|
resolve: Function;
|
|
reject: Function;
|
|
timeoutHandle: NodeJS.Timeout;
|
|
} | null = null;
|
|
|
|
/** Resolved when Python prints {"type":"ready"} — stdin loop is live. */
|
|
private readyPromise: Promise<void>;
|
|
private resolveReady!: () => void;
|
|
private rejectReady!: (err: Error) => void;
|
|
/** Accumulates stdout until the READY marker is seen. */
|
|
private startupBuffer: string = "";
|
|
/** True after READY marker detected; persistent handler takes over. */
|
|
private readyDetected: boolean = false;
|
|
|
|
/**
|
|
* Constructor for the KiCAD MCP Server
|
|
* @param kicadScriptPath Path to the Python KiCAD interface script
|
|
* @param logLevel Log level for the server
|
|
*/
|
|
constructor(kicadScriptPath: string, logLevel: "error" | "warn" | "info" | "debug" = "info") {
|
|
// Set up the logger
|
|
logger.setLogLevel(logLevel);
|
|
|
|
// Check if KiCAD script exists
|
|
this.kicadScriptPath = kicadScriptPath;
|
|
if (!existsSync(this.kicadScriptPath)) {
|
|
throw new Error(`KiCAD interface script not found: ${this.kicadScriptPath}`);
|
|
}
|
|
|
|
// Initialize the MCP server
|
|
this.server = new McpServer({
|
|
name: "kicad-mcp-server",
|
|
version: "1.0.0",
|
|
description: "MCP server for KiCAD PCB design operations",
|
|
});
|
|
// Create the ready promise (resolved when Python sends {"type":"ready"})
|
|
this.readyPromise = new Promise((resolve, reject) => {
|
|
this.resolveReady = resolve;
|
|
this.rejectReady = reject;
|
|
});
|
|
|
|
// Initialize STDIO transport
|
|
this.stdioTransport = new StdioServerTransport();
|
|
logger.info("Using STDIO transport for local communication");
|
|
|
|
// Register tools, resources, and prompts
|
|
this.registerAll();
|
|
}
|
|
|
|
/**
|
|
* Register all tools, resources, and prompts
|
|
*/
|
|
private registerAll(): void {
|
|
logger.info("Registering KiCAD tools, resources, and prompts...");
|
|
|
|
// Register router tools FIRST (for tool discovery and execution)
|
|
registerRouterTools(this.server, this.callKicadScript.bind(this));
|
|
|
|
// Register all tools
|
|
registerProjectTools(this.server, this.callKicadScript.bind(this));
|
|
registerBoardTools(this.server, this.callKicadScript.bind(this));
|
|
registerComponentTools(this.server, this.callKicadScript.bind(this));
|
|
registerRoutingTools(this.server, this.callKicadScript.bind(this));
|
|
registerDesignRuleTools(this.server, this.callKicadScript.bind(this));
|
|
registerExportTools(this.server, this.callKicadScript.bind(this));
|
|
registerSchematicTools(this.server, this.callKicadScript.bind(this));
|
|
registerLibraryTools(this.server, this.callKicadScript.bind(this));
|
|
registerSymbolLibraryTools(this.server, this.callKicadScript.bind(this));
|
|
registerSchematicHierarchyTools(this.server, this.callKicadScript.bind(this));
|
|
registerJLCPCBApiTools(this.server, this.callKicadScript.bind(this));
|
|
registerDatasheetTools(this.server, this.callKicadScript.bind(this));
|
|
registerFootprintTools(this.server, this.callKicadScript.bind(this));
|
|
registerSymbolCreatorTools(this.server, this.callKicadScript.bind(this));
|
|
registerUITools(this.server, this.callKicadScript.bind(this));
|
|
registerFreeroutingTools(this.server, this.callKicadScript.bind(this));
|
|
|
|
// Register all resources
|
|
registerProjectResources(this.server, this.callKicadScript.bind(this));
|
|
registerBoardResources(this.server, this.callKicadScript.bind(this));
|
|
registerComponentResources(this.server, this.callKicadScript.bind(this));
|
|
registerLibraryResources(this.server, this.callKicadScript.bind(this));
|
|
|
|
// Register all prompts
|
|
registerComponentPrompts(this.server);
|
|
registerRoutingPrompts(this.server);
|
|
registerDesignPrompts(this.server);
|
|
registerFootprintPrompts(this.server);
|
|
|
|
logger.info("All KiCAD tools, resources, and prompts registered");
|
|
}
|
|
|
|
/**
|
|
* Validate prerequisites before starting the server
|
|
*/
|
|
private async validatePrerequisites(pythonExe: string): Promise<boolean> {
|
|
const isWindows = process.platform === "win32";
|
|
const isLinux = process.platform !== "win32" && process.platform !== "darwin";
|
|
const errors: string[] = [];
|
|
|
|
// Check if Python executable exists (for absolute paths) or is executable (for commands)
|
|
const isAbsolutePath =
|
|
pythonExe.startsWith("/") || pythonExe.startsWith("C:") || pythonExe.startsWith("\\");
|
|
let pythonExecutableAvailable = true;
|
|
|
|
if (isAbsolutePath) {
|
|
// Absolute path: use existsSync
|
|
if (!existsSync(pythonExe)) {
|
|
pythonExecutableAvailable = false;
|
|
errors.push(`Python executable not found: ${pythonExe}`);
|
|
|
|
if (isWindows) {
|
|
errors.push("Windows: Install KiCAD 9.0+ from https://www.kicad.org/download/windows/");
|
|
errors.push("Or run: .\\setup-windows.ps1 for automatic configuration");
|
|
} else if (isLinux) {
|
|
errors.push("Linux: Install KiCAD 9.0+ or set KICAD_PYTHON environment variable");
|
|
errors.push("Set KICAD_PYTHON to specify a custom Python path");
|
|
}
|
|
}
|
|
} else {
|
|
// Command name: verify it's executable via --version test
|
|
logger.info(`Validating command-based Python executable: ${pythonExe}`);
|
|
try {
|
|
const { stdout } = await new Promise<{
|
|
stdout: string;
|
|
stderr: string;
|
|
}>((resolve, reject) => {
|
|
exec(
|
|
`"${pythonExe}" --version`,
|
|
{
|
|
timeout: 3000,
|
|
env: { ...process.env },
|
|
},
|
|
(error: any, stdout: string, stderr: string) => {
|
|
if (error) {
|
|
reject(error);
|
|
} else {
|
|
resolve({ stdout, stderr });
|
|
}
|
|
},
|
|
);
|
|
});
|
|
|
|
logger.info(`Python version check passed: ${stdout.trim()}`);
|
|
} catch (error: any) {
|
|
pythonExecutableAvailable = false;
|
|
errors.push(`Python executable not found in PATH: ${pythonExe}`);
|
|
errors.push(`Error: ${error.message}`);
|
|
errors.push("Set KICAD_PYTHON environment variable to specify full path");
|
|
|
|
if (isLinux) {
|
|
errors.push("");
|
|
errors.push("Linux troubleshooting:");
|
|
errors.push("1. Check if python3 is installed: which python3");
|
|
errors.push("2. Install KiCAD: sudo apt install kicad (Ubuntu/Debian)");
|
|
errors.push("3. Set KICAD_PYTHON=/usr/bin/python3 in your MCP config");
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check if kicad_interface.py exists
|
|
if (!existsSync(this.kicadScriptPath)) {
|
|
errors.push(`KiCAD interface script not found: ${this.kicadScriptPath}`);
|
|
}
|
|
|
|
// Check if dist/index.js exists (if running from compiled code)
|
|
const distPath = join(dirname(dirname(this.kicadScriptPath)), "dist", "index.js");
|
|
if (!existsSync(distPath)) {
|
|
errors.push("Project not built. Run: npm run build");
|
|
}
|
|
|
|
// Try to test pcbnew import (quick validation)
|
|
if (pythonExecutableAvailable && existsSync(this.kicadScriptPath)) {
|
|
logger.info("Validating pcbnew module access...");
|
|
|
|
const testCommand = `"${pythonExe}" -c "import pcbnew; print('OK')"`;
|
|
|
|
try {
|
|
const { stdout, stderr } = await new Promise<{
|
|
stdout: string;
|
|
stderr: string;
|
|
}>((resolve, reject) => {
|
|
exec(
|
|
testCommand,
|
|
{
|
|
timeout: 5000,
|
|
env: { ...process.env },
|
|
},
|
|
(error: any, stdout: string, stderr: string) => {
|
|
if (error) {
|
|
reject(error);
|
|
} else {
|
|
resolve({ stdout, stderr });
|
|
}
|
|
},
|
|
);
|
|
});
|
|
|
|
if (!stdout.includes("OK")) {
|
|
errors.push("pcbnew module import test failed");
|
|
errors.push(`Output: ${stdout}`);
|
|
errors.push(`Errors: ${stderr}`);
|
|
|
|
if (isWindows) {
|
|
errors.push("");
|
|
errors.push("Windows troubleshooting:");
|
|
errors.push(
|
|
"1. Set PYTHONPATH=C:\\Program Files\\KiCad\\9.0\\lib\\python3\\dist-packages",
|
|
);
|
|
errors.push(
|
|
'2. Test: "C:\\Program Files\\KiCad\\9.0\\bin\\python.exe" -c "import pcbnew"',
|
|
);
|
|
errors.push("3. Run: .\\setup-windows.ps1 for automatic fix");
|
|
errors.push("4. See: docs/WINDOWS_TROUBLESHOOTING.md");
|
|
}
|
|
} else {
|
|
logger.info("✓ pcbnew module validated successfully");
|
|
}
|
|
} catch (error: any) {
|
|
errors.push(`pcbnew validation failed: ${error.message}`);
|
|
|
|
if (isWindows) {
|
|
errors.push("");
|
|
errors.push("This usually means:");
|
|
errors.push("- KiCAD is not installed");
|
|
errors.push("- PYTHONPATH is incorrect");
|
|
errors.push("- Python cannot find pcbnew module");
|
|
errors.push("");
|
|
errors.push("Quick fix: Run .\\setup-windows.ps1");
|
|
}
|
|
}
|
|
}
|
|
|
|
// Log all errors
|
|
if (errors.length > 0) {
|
|
logger.error("=".repeat(70));
|
|
logger.error("STARTUP VALIDATION FAILED");
|
|
logger.error("=".repeat(70));
|
|
errors.forEach((err) => logger.error(err));
|
|
logger.error("=".repeat(70));
|
|
|
|
// Also write to stderr for Claude Desktop to capture
|
|
process.stderr.write("\n" + "=".repeat(70) + "\n");
|
|
process.stderr.write("KiCAD MCP Server - Startup Validation Failed\n");
|
|
process.stderr.write("=".repeat(70) + "\n");
|
|
errors.forEach((err) => process.stderr.write(err + "\n"));
|
|
process.stderr.write("=".repeat(70) + "\n\n");
|
|
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Start the MCP server and the Python KiCAD interface
|
|
*/
|
|
async start(): Promise<void> {
|
|
try {
|
|
logger.info("Starting KiCAD MCP server...");
|
|
|
|
// Start the Python process for KiCAD scripting
|
|
logger.info(`Starting Python process with script: ${this.kicadScriptPath}`);
|
|
const pythonExe = findPythonExecutable(this.kicadScriptPath);
|
|
|
|
logger.info(`Using Python executable: ${pythonExe}`);
|
|
|
|
// Validate prerequisites
|
|
const isValid = await this.validatePrerequisites(pythonExe);
|
|
if (!isValid) {
|
|
throw new Error("Prerequisites validation failed. See logs above for details.");
|
|
}
|
|
// PYTHONPATH precedence: explicit env override → site-packages derived
|
|
// from the detected KiCAD python (any version / install location) →
|
|
// legacy 9.0 fallback as a last resort.
|
|
const derivedSitePackages = deriveKiCadSitePackages(pythonExe);
|
|
if (derivedSitePackages && !process.env.PYTHONPATH) {
|
|
logger.info(`Using KiCAD site-packages: ${derivedSitePackages}`);
|
|
}
|
|
this.pythonProcess = spawn(pythonExe, [this.kicadScriptPath], {
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
env: {
|
|
...process.env,
|
|
PYTHONPATH:
|
|
process.env.PYTHONPATH ||
|
|
derivedSitePackages ||
|
|
"C:/Program Files/KiCad/9.0/lib/python3/dist-packages",
|
|
},
|
|
});
|
|
|
|
// Listen for process exit
|
|
this.pythonProcess.on("exit", (code, signal) => {
|
|
logger.warn(`Python process exited with code ${code} and signal ${signal}`);
|
|
this.pythonProcess = null;
|
|
});
|
|
|
|
// Listen for process errors
|
|
this.pythonProcess.on("error", (err) => {
|
|
logger.error(`Python process error: ${err.message}`);
|
|
});
|
|
|
|
// Set up error logging for stderr
|
|
if (this.pythonProcess.stderr) {
|
|
this.pythonProcess.stderr.on("data", (data: Buffer) => {
|
|
logger.error(`Python stderr: ${data.toString()}`);
|
|
});
|
|
}
|
|
|
|
// ——— Phase 1: stdout handler that detects the READY marker ———
|
|
// Before Python reaches main() it may spend 55-65 s on wxApp init.
|
|
// The stdin loop is only live after main() prints {"type":"ready"}.
|
|
// Until then we buffer everything and scan for that exact JSON line.
|
|
if (this.pythonProcess.stdout) {
|
|
this.pythonProcess.stdout.on("data", (data: Buffer) => {
|
|
if (this.readyDetected) {
|
|
// Persistent handler (post-warm-up)
|
|
this.handlePythonResponse(data);
|
|
} else {
|
|
this.startupBuffer += data.toString();
|
|
const lines = this.startupBuffer.split("\n");
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i].trim();
|
|
if (!line) continue;
|
|
try {
|
|
const obj = JSON.parse(line);
|
|
if (obj.type === "ready") {
|
|
logger.info("Python process READY — stdin loop is live");
|
|
this.readyDetected = true;
|
|
// Replay any remaining buffered lines through the persistent handler
|
|
const remaining = lines.slice(i + 1).join("\n");
|
|
if (remaining.trim()) {
|
|
this.handlePythonResponse(Buffer.from(remaining));
|
|
}
|
|
this.resolveReady();
|
|
return;
|
|
}
|
|
} catch {
|
|
// Not valid JSON yet; keep buffering
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// ——— Phase 2: wait for Python READY ———
|
|
logger.info("Waiting for Python process to be ready...");
|
|
await this.waitForReady(120_000);
|
|
logger.info("Python process is ready.");
|
|
// ——— Phase 3: connect MCP transport immediately ———
|
|
// The transport must be live before any client timeout fires,
|
|
// regardless of how long warm-up takes.
|
|
logger.info("Connecting MCP server to STDIO transport...");
|
|
try {
|
|
await this.server.connect(this.stdioTransport);
|
|
logger.info("Successfully connected to STDIO transport");
|
|
} catch (error) {
|
|
logger.error(`Failed to connect to STDIO transport: ${error}`);
|
|
throw error;
|
|
}
|
|
// ——— Phase 4: background warm-up (does not block MCP) ———
|
|
// Warm-up can take 55-125 s (wxApp + symbol library parse), but
|
|
// the MCP transport is already live so the client timeout does not
|
|
// apply. Tools invoked during warm-up will work; the first
|
|
// search_symbols may be slower if warm-up hasn't completed yet.
|
|
logger.info("Sending warm-up command (background)...");
|
|
await this.runWarmup(120_000);
|
|
logger.info("Warm-up complete — pcbnew/wxApp initialised");
|
|
|
|
|
|
// Write a ready message to stderr (for debugging)
|
|
process.stderr.write("KiCAD MCP SERVER READY\n");
|
|
|
|
logger.info("KiCAD MCP server started and ready");
|
|
} catch (error) {
|
|
logger.error(`Failed to start KiCAD MCP server: ${error}`);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stop the MCP server and clean up resources
|
|
*/
|
|
async stop(): Promise<void> {
|
|
logger.info("Stopping KiCAD MCP server...");
|
|
|
|
// Kill the Python process if it's running
|
|
if (this.pythonProcess) {
|
|
this.pythonProcess.kill();
|
|
this.pythonProcess = null;
|
|
}
|
|
|
|
logger.info("KiCAD MCP server stopped");
|
|
}
|
|
|
|
/**
|
|
* Wait for the Python process to print {"type":"ready"} on stdout,
|
|
* signalling that the stdin loop is live and the process can accept
|
|
* commands.
|
|
*/
|
|
private async waitForReady(timeoutMs: number): Promise<void> {
|
|
return new Promise((_resolve, reject) => {
|
|
const timeout = setTimeout(() => {
|
|
reject(new Error(
|
|
`Python process did not send READY within ${timeoutMs / 1000} s`
|
|
));
|
|
}, timeoutMs);
|
|
this.readyPromise.then(() => {
|
|
clearTimeout(timeout);
|
|
_resolve();
|
|
}).catch(reject);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Send a _warmup command to the Python process to force full
|
|
* pcbnew/wxApp initialisation. On macOS this can take 55-65 s;
|
|
* we use a generous timeout so the cost is paid during startup
|
|
* rather than on the first user tool call.
|
|
*
|
|
* Wires into the existing request infrastructure so the persistent
|
|
* stdout handler (already active post-READY) processes the response.
|
|
*/
|
|
private async runWarmup(timeoutMs: number): Promise<void> {
|
|
return new Promise<void>((resolve) => {
|
|
if (!this.pythonProcess || !this.pythonProcess.stdin) {
|
|
logger.warn("Python process not running — skipping warm-up");
|
|
resolve();
|
|
return;
|
|
}
|
|
|
|
const requestStr = JSON.stringify({ command: "_warmup", params: {} });
|
|
this.responseBuffer = "";
|
|
|
|
const timeoutHandle = setTimeout(() => {
|
|
logger.warn(
|
|
`Warm-up timed out after ${timeoutMs / 1000} s — ` +
|
|
"continuing without full initialisation"
|
|
);
|
|
this.responseBuffer = "";
|
|
this.processingRequest = false;
|
|
this.currentRequestHandler = null;
|
|
resolve();
|
|
}, timeoutMs);
|
|
|
|
// Use the existing request infrastructure to avoid race conditions
|
|
// with the persistent stdout handler.
|
|
this.processingRequest = true;
|
|
this.currentRequestHandler = {
|
|
resolve: (result: any) => {
|
|
clearTimeout(timeoutHandle);
|
|
this.processingRequest = false;
|
|
this.currentRequestHandler = null;
|
|
if (result?.success) {
|
|
logger.info(
|
|
`Warm-up succeeded: pcbnew ${result.version} (${result.elapsed_s}s)`
|
|
);
|
|
} else {
|
|
logger.warn(
|
|
`Warm-up returned failure: ${result?.message || "unknown"} — continuing`
|
|
);
|
|
}
|
|
resolve();
|
|
},
|
|
reject: (err: Error) => {
|
|
clearTimeout(timeoutHandle);
|
|
this.processingRequest = false;
|
|
this.currentRequestHandler = null;
|
|
logger.warn(`Warm-up failed: ${err.message} — continuing`);
|
|
resolve(); // don't fail the whole server
|
|
},
|
|
timeoutHandle,
|
|
};
|
|
|
|
this.pythonProcess.stdin.write(requestStr + "\n");
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Call the KiCAD scripting interface to execute commands
|
|
*
|
|
* @param command The command to execute
|
|
* @param params The parameters for the command
|
|
* @returns The result of the command execution
|
|
*/
|
|
private async callKicadScript(command: string, params: any): Promise<any> {
|
|
return new Promise((resolve, reject) => {
|
|
// Check if Python process is running
|
|
if (!this.pythonProcess) {
|
|
logger.error("Python process is not running");
|
|
reject(new Error("Python process for KiCAD scripting is not running"));
|
|
return;
|
|
}
|
|
|
|
// Determine timeout based on command type
|
|
// DRC and export operations need longer timeouts for large boards
|
|
let commandTimeout = 30000; // Default 30 seconds
|
|
const longRunningCommands = [
|
|
"run_drc",
|
|
"export_gerber",
|
|
"export_pdf",
|
|
"export_3d",
|
|
"sync_schematic_to_board",
|
|
"list_schematic_nets",
|
|
"list_schematic_labels",
|
|
"get_schematic_view",
|
|
];
|
|
if (longRunningCommands.includes(command)) {
|
|
commandTimeout = 600000; // 10 minutes for long operations
|
|
logger.info(`Using extended timeout (${commandTimeout / 1000}s) for command: ${command}`);
|
|
}
|
|
|
|
// Add request to queue with timeout info
|
|
this.requestQueue.push({
|
|
request: { command, params, timeout: commandTimeout },
|
|
resolve,
|
|
reject,
|
|
});
|
|
|
|
// Process the queue if not already processing
|
|
if (!this.processingRequest) {
|
|
this.processNextRequest();
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Handle incoming data from Python process stdout
|
|
* This is a persistent handler that processes all responses
|
|
*/
|
|
private handlePythonResponse(data: Buffer): void {
|
|
const chunk = data.toString();
|
|
logger.debug(`Received data chunk: ${chunk.length} bytes`);
|
|
this.responseBuffer += chunk;
|
|
|
|
// Try to parse complete JSON responses (may have multiple or partial)
|
|
this.tryParseResponse();
|
|
}
|
|
|
|
/**
|
|
* Try to parse a complete JSON response from the buffer.
|
|
*
|
|
* Responses from the Python side are single-line JSON terminated by '\n'
|
|
* (written via _write_response). The buffer may also contain non-JSON
|
|
* preamble lines (e.g. C-level warnings from pcbnew that leaked to the
|
|
* response fd before the redirect took effect).
|
|
*
|
|
* Strategy:
|
|
* 1. Fast path: JSON.parse(buffer) — works for clean, complete responses
|
|
* (JSON.parse tolerates trailing whitespace/newlines).
|
|
* 2. If that fails and the buffer has no '\n' yet, the response line is
|
|
* still arriving in chunks — keep collecting.
|
|
* 3. If the buffer has '\n', split into lines and search from the END for
|
|
* a parseable JSON line. This avoids prematurely resolving with a
|
|
* truncated JSON object when a large response is still chunking in.
|
|
*/
|
|
private tryParseResponse(): void {
|
|
if (!this.currentRequestHandler) {
|
|
// No pending request, clear buffer if it has data (shouldn't happen)
|
|
if (this.responseBuffer.trim()) {
|
|
logger.warn(
|
|
`Received data with no pending request: ${this.responseBuffer.substring(0, 100)}...`,
|
|
);
|
|
this.responseBuffer = "";
|
|
}
|
|
return;
|
|
}
|
|
|
|
let result: any;
|
|
|
|
// Fast path: try to parse the response as JSON. Handles the common
|
|
// case of a clean, complete JSON response (possibly with trailing \n).
|
|
try {
|
|
result = JSON.parse(this.responseBuffer);
|
|
} catch {
|
|
// Direct parse failed. Either the response is still arriving in
|
|
// chunks, or the buffer has non-JSON preamble from pcbnew.
|
|
//
|
|
// The Python side writes each response as a single line of JSON
|
|
// terminated by \n. We use the newline as the completion signal:
|
|
// if there is no \n in the buffer yet, the JSON line is still
|
|
// being assembled from chunks — keep collecting.
|
|
if (!this.responseBuffer.includes("\n")) {
|
|
return;
|
|
}
|
|
|
|
// Buffer contains newline(s). Split into lines and look for a
|
|
// complete JSON object, searching from the END so that preamble
|
|
// lines (which may themselves contain '{') are skipped.
|
|
const lines = this.responseBuffer.split("\n");
|
|
let jsonLineIndex = -1;
|
|
|
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
const line = lines[i].trim();
|
|
if (line.length === 0) continue;
|
|
if (!line.startsWith("{")) continue;
|
|
|
|
try {
|
|
result = JSON.parse(line);
|
|
jsonLineIndex = i;
|
|
break;
|
|
} catch {
|
|
// Looks like JSON but doesn't parse — could be an incomplete
|
|
// final line still being chunked. Keep collecting.
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if (jsonLineIndex < 0) {
|
|
// No parseable JSON line found yet. Either only preamble has
|
|
// arrived, or the JSON line is split across the last \n boundary
|
|
// and is still incomplete. Keep collecting.
|
|
return;
|
|
}
|
|
|
|
// Log any preceding non-JSON lines as preamble
|
|
const preambleLines = lines.slice(0, jsonLineIndex).filter((l) => l.trim().length > 0);
|
|
if (preambleLines.length > 0) {
|
|
logger.warn(
|
|
`Stripped non-JSON preamble from Python response: ${preambleLines.join(" | ")}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// If we get here, we have a valid JSON response
|
|
logger.debug(`Completed KiCAD command with result: ${result.success ? "success" : "failure"}`);
|
|
|
|
// Clear the timeout since we got a response
|
|
if (this.currentRequestHandler.timeoutHandle) {
|
|
clearTimeout(this.currentRequestHandler.timeoutHandle);
|
|
}
|
|
|
|
// Get the handler before clearing
|
|
const handler = this.currentRequestHandler;
|
|
|
|
// Clear state
|
|
this.responseBuffer = "";
|
|
this.currentRequestHandler = null;
|
|
this.processingRequest = false;
|
|
|
|
// Resolve the promise with the result
|
|
handler.resolve(result);
|
|
|
|
// Process next request if any
|
|
setTimeout(() => this.processNextRequest(), 0);
|
|
}
|
|
|
|
/**
|
|
* Process the next request in the queue
|
|
*/
|
|
private processNextRequest(): void {
|
|
// If no more requests or already processing, return
|
|
if (this.requestQueue.length === 0 || this.processingRequest) {
|
|
return;
|
|
}
|
|
|
|
// Set processing flag
|
|
this.processingRequest = true;
|
|
|
|
// Get the next request
|
|
const { request, resolve, reject } = this.requestQueue.shift()!;
|
|
|
|
try {
|
|
logger.debug(`Processing KiCAD command: ${request.command}`);
|
|
|
|
// Format the command and parameters as JSON
|
|
const requestStr = JSON.stringify(request);
|
|
|
|
// Clear response buffer for new request
|
|
this.responseBuffer = "";
|
|
|
|
// Set a timeout (use command-specific timeout or default)
|
|
const timeoutDuration = request.timeout || 30000;
|
|
const timeoutHandle = setTimeout(() => {
|
|
logger.error(`Command timeout after ${timeoutDuration / 1000}s: ${request.command}`);
|
|
logger.error(`Buffer contents: ${this.responseBuffer.substring(0, 200)}...`);
|
|
|
|
// Clear state
|
|
this.responseBuffer = "";
|
|
this.currentRequestHandler = null;
|
|
this.processingRequest = false;
|
|
|
|
// Reject the promise
|
|
reject(new Error(`Command timeout after ${timeoutDuration / 1000}s: ${request.command}`));
|
|
|
|
// Process next request
|
|
setTimeout(() => this.processNextRequest(), 0);
|
|
}, timeoutDuration);
|
|
|
|
// Store the current request handler
|
|
this.currentRequestHandler = { resolve, reject, timeoutHandle };
|
|
|
|
// Write the request to the Python process
|
|
logger.debug(`Sending request: ${requestStr}`);
|
|
this.pythonProcess?.stdin?.write(requestStr + "\n");
|
|
} catch (error) {
|
|
logger.error(`Error processing request: ${error}`);
|
|
|
|
// Reset processing flag
|
|
this.processingRequest = false;
|
|
this.currentRequestHandler = null;
|
|
|
|
// Process next request
|
|
setTimeout(() => this.processNextRequest(), 0);
|
|
|
|
// Reject the promise
|
|
reject(error);
|
|
}
|
|
}
|
|
}
|