fix: reconnect IPC backend after KiCad starts (#140)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
23
CHANGELOG.md
23
CHANGELOG.md
@@ -28,6 +28,22 @@ All notable changes to the KiCAD MCP Server project are documented here.
|
||||
`.kicad_sch` path) for stateless callers, so project libraries can be
|
||||
resolved without first calling `open_project`.
|
||||
|
||||
- **IPC backend runtime reconnect**: MCP no longer stays on SWIG for the
|
||||
entire process when it starts before KiCAD. IPC-capable board tools now retry
|
||||
the IPC connection when KiCAD is running, refresh the live board API when a
|
||||
board becomes available, and report `_backend: "ipc"` when they actually use
|
||||
the IPC path. `check_kicad_ui`, `launch_kicad_ui`, and `get_backend_info`
|
||||
now include live backend status instead of only reflecting startup state.
|
||||
|
||||
- **Windows KiCAD Python discovery**: Windows startup now scans per-user KiCAD
|
||||
installs under `%LOCALAPPDATA%\Programs\KiCad` in addition to machine-wide
|
||||
installs under `C:\Program Files\KiCad` and `C:\Program Files (x86)\KiCad`,
|
||||
so user-scope installs no longer require a manual `KICAD_PYTHON` override.
|
||||
|
||||
- **IPC board size on KiCAD 10**: `get_board_info` now handles KiCAD 10 IPC
|
||||
`Box2` objects that expose `pos` / `size` instead of `min` / `max`, avoiding
|
||||
a zero-size board result with an attribute error.
|
||||
|
||||
- **Schematic symbol lookup**: `get_schematic_component`,
|
||||
`edit_schematic_component`, `set_schematic_component_property`,
|
||||
`remove_schematic_component_property`, and `delete_schematic_component`
|
||||
@@ -157,6 +173,9 @@ All notable changes to the KiCAD MCP Server project are documented here.
|
||||
this out explicitly so agents know they can use it to inspect MPN,
|
||||
Manufacturer, Distributor PN and other BOM fields without a separate call.
|
||||
|
||||
- `query_traces`: added to the IPC-capable board command path so trace reads
|
||||
can use live KiCAD board data when IPC is connected.
|
||||
|
||||
### New MCP Prompt
|
||||
|
||||
- `component_sourcing_properties` — Guides the LLM through attaching BOM and
|
||||
@@ -175,6 +194,10 @@ All notable changes to the KiCAD MCP Server project are documented here.
|
||||
no-op removal, special-character escaping, UUID preservation, and the two
|
||||
new convenience tools.
|
||||
|
||||
- `tests/test_backend_metadata.py`: regression coverage for backend metadata,
|
||||
runtime IPC reconnect after KiCAD starts, IPC-backed `query_traces`, and
|
||||
KiCAD 10 IPC `Box2` board-size compatibility.
|
||||
|
||||
### Removed
|
||||
|
||||
- `add_schematic_junction` MCP tool has been removed. Junctions are now
|
||||
|
||||
18
README.md
18
README.md
@@ -169,6 +169,9 @@ We are currently implementing and testing the KiCAD 9.0 IPC API for real-time UI
|
||||
- Changes made via MCP tools appear immediately in the KiCAD UI
|
||||
- No manual reload required when IPC is active
|
||||
- Hybrid backend: uses IPC when available, falls back to SWIG API
|
||||
- IPC runtime reconnect: if MCP has fallen back to SWIG, IPC-capable board
|
||||
tools retry IPC after KiCAD launches instead of staying on SWIG for the entire
|
||||
session
|
||||
- 20+ commands now support IPC including routing, component placement, and zone operations
|
||||
|
||||
Note: IPC features are under active development and testing. Enable IPC in KiCAD via Preferences > Plugins > Enable IPC API Server.
|
||||
@@ -418,7 +421,7 @@ See [Freerouting Guide](docs/FREEROUTING_GUIDE.md) for setup and usage.
|
||||
|
||||
### Required Software
|
||||
|
||||
**KiCAD 9.0 or Higher**
|
||||
**KiCAD 9.0 or higher**
|
||||
|
||||
- Download from [kicad.org/download](https://www.kicad.org/download/)
|
||||
- Must include Python module (pcbnew)
|
||||
@@ -463,7 +466,7 @@ Choose one:
|
||||
### Linux (Ubuntu/Debian)
|
||||
|
||||
```bash
|
||||
# Install KiCAD 9.0
|
||||
# Install KiCAD 9.0 or higher
|
||||
sudo add-apt-repository --yes ppa:kicad/kicad-9.0-releases
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y kicad kicad-libraries
|
||||
@@ -495,7 +498,9 @@ cd KiCAD-MCP-Server
|
||||
|
||||
The script will:
|
||||
|
||||
- Detect KiCAD installation
|
||||
- Detect KiCAD installations, including both machine-wide installs under
|
||||
`C:\Program Files\KiCad` and per-user installs under
|
||||
`%LOCALAPPDATA%\Programs\KiCad`
|
||||
- Verify prerequisites
|
||||
- Install dependencies
|
||||
- Build project
|
||||
@@ -693,7 +698,8 @@ Edit configuration file:
|
||||
**Platform-specific PYTHONPATH:**
|
||||
|
||||
- **Linux:** `/usr/lib/kicad/lib/python3/dist-packages`
|
||||
- **Windows:** `C:\Program Files\KiCad\9.0\lib\python3\dist-packages`
|
||||
- **Windows:** `C:\Program Files\KiCad\10.0\lib\python3\dist-packages` or
|
||||
`%LOCALAPPDATA%\Programs\KiCad\10.0\lib\python3\dist-packages`
|
||||
- **macOS:** `/Applications/KiCad/KiCad.app/Contents/Frameworks/Python.framework/Versions/3.9/lib/python3.9/site-packages`
|
||||
|
||||
#### Linux Python Detection
|
||||
@@ -1076,7 +1082,7 @@ npm run format
|
||||
**Solutions:**
|
||||
|
||||
1. Run automated diagnostics: `.\setup-windows.ps1`
|
||||
2. Verify Python path uses double backslashes: `C:\\Program Files\\KiCad\\9.0`
|
||||
2. Verify Python path uses double backslashes: `C:\\Program Files\\KiCad\\10.0`
|
||||
3. Check Windows Event Viewer for Node.js errors
|
||||
4. See [Windows Troubleshooting Guide](docs/WINDOWS_TROUBLESHOOTING.md)
|
||||
|
||||
@@ -1118,7 +1124,7 @@ See [STATUS_SUMMARY.md](docs/STATUS_SUMMARY.md) for the complete status matrix a
|
||||
|
||||
**IPC Backend (Experimental):**
|
||||
|
||||
- Real-time UI synchronization via KiCAD 9.0 IPC API
|
||||
- Real-time UI synchronization via the KiCAD IPC API
|
||||
- 21 IPC-enabled commands with automatic SWIG fallback
|
||||
- Hybrid footprint loading (SWIG for library access, IPC for placement)
|
||||
|
||||
|
||||
@@ -11,7 +11,13 @@
|
||||
|
||||
The IPC backend provides real-time UI synchronization with KiCAD 9.0+ via the official IPC API. When KiCAD is running with IPC enabled, commands can update the KiCAD UI immediately without requiring manual reload.
|
||||
|
||||
This feature is experimental and under active testing. The server uses a hybrid approach: IPC when available, automatic fallback to SWIG when IPC is not connected.
|
||||
This feature is experimental and under active testing. The server uses a hybrid
|
||||
approach: IPC when available, automatic fallback to SWIG when IPC is not
|
||||
connected, and runtime reconnect when KiCAD becomes available after the MCP
|
||||
server has already started. Tools with IPC implementations recheck IPC
|
||||
availability at runtime and reconnect if KiCAD is available, which prevents the
|
||||
MCP from staying in SWIG for the entire session after an initial failure to
|
||||
connect to IPC.
|
||||
|
||||
## Key Differences
|
||||
|
||||
@@ -33,6 +39,7 @@ The following MCP commands have IPC handlers:
|
||||
| `add_via` | `_ipc_add_via` | Implemented |
|
||||
| `add_net` | `_ipc_add_net` | Implemented |
|
||||
| `delete_trace` | `_ipc_delete_trace` | Falls back to SWIG |
|
||||
| `query_traces` | `_ipc_query_traces` | Implemented |
|
||||
| `get_nets_list` | `_ipc_get_nets_list` | Implemented |
|
||||
| `add_copper_pour` | `_ipc_add_copper_pour` | Implemented |
|
||||
| `refill_zones` | `_ipc_refill_zones` | Implemented |
|
||||
@@ -59,6 +66,7 @@ The following MCP commands have IPC handlers:
|
||||
- Auto-detect socket path (`/tmp/kicad/api.sock`)
|
||||
- Version checking and validation
|
||||
- Auto-fallback to SWIG when IPC unavailable
|
||||
- Runtime reconnect from SWIG to IPC when KiCAD starts after MCP
|
||||
- Change notification callbacks
|
||||
|
||||
**Board Operations:**
|
||||
@@ -86,6 +94,7 @@ The following MCP commands have IPC handlers:
|
||||
- Get all tracks
|
||||
- Get all vias
|
||||
- Get all nets
|
||||
- Query traces from the live board with net, layer, and bounding-box filters
|
||||
|
||||
**Zone Operations:**
|
||||
|
||||
@@ -119,6 +128,30 @@ The following MCP commands have IPC handlers:
|
||||
pip install kicad-python
|
||||
```
|
||||
|
||||
### Runtime Backend Selection
|
||||
|
||||
At startup, the server attempts IPC first when `KICAD_BACKEND` is `auto` or
|
||||
`ipc`. If KiCAD is not running yet, `auto` mode falls back to SWIG so file-based
|
||||
operations can still work.
|
||||
|
||||
IPC-capable board tools now retry the IPC connection at runtime. This means the
|
||||
following workflow can switch from SWIG to IPC without restarting the MCP
|
||||
server:
|
||||
|
||||
1. Start the MCP server before KiCAD is running.
|
||||
2. Launch KiCAD manually or with `launch_kicad_ui`.
|
||||
3. Open a board with IPC enabled in KiCAD.
|
||||
4. Call a normal IPC-capable board tool such as `get_board_info`,
|
||||
`get_layer_list`, `get_component_list`, `get_nets_list`, or `query_traces`.
|
||||
|
||||
When the reconnect succeeds, tool responses include `_backend: "ipc"` and
|
||||
`_realtime: true`. If IPC is still unavailable or no board API can be opened,
|
||||
the server continues to use SWIG and reports `_backend: "swig"` for the result.
|
||||
|
||||
Use `check_kicad_ui`, `launch_kicad_ui`, or `get_backend_info` to inspect the
|
||||
current live backend status. These responses include `backend`,
|
||||
`realtime_sync`, and `ipc_connected`.
|
||||
|
||||
### Testing
|
||||
|
||||
Run the test script to verify IPC functionality:
|
||||
@@ -164,7 +197,9 @@ Run the test script to verify IPC functionality:
|
||||
2. **Project creation**: Not supported via IPC, uses file system
|
||||
3. **Footprint library access**: Uses hybrid approach (SWIG loads from library, IPC places)
|
||||
4. **Delete trace**: Falls back to SWIG (IPC API doesn't support direct deletion)
|
||||
5. **Some operations may not work as expected**: This is experimental code
|
||||
5. **Reconnect still requires IPC prerequisites**: Runtime reconnect only
|
||||
succeeds when KiCAD is running with IPC enabled and a board is available
|
||||
6. **Some operations may not work as expected**: This is experimental code
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@@ -173,6 +208,8 @@ Run the test script to verify IPC functionality:
|
||||
- Ensure KiCAD is running
|
||||
- Enable IPC API: `Preferences > Plugins > Enable IPC API Server`
|
||||
- Check if a board is open
|
||||
- If MCP started before KiCAD, call `check_kicad_ui` or retry an IPC-capable
|
||||
board tool after KiCAD and the board are ready
|
||||
|
||||
### "kicad-python not found"
|
||||
|
||||
|
||||
@@ -71,7 +71,10 @@ Check if KiCAD is currently running.
|
||||
"command": "/usr/bin/pcbnew /tmp/project.kicad_pcb"
|
||||
}
|
||||
],
|
||||
"message": "KiCAD is running"
|
||||
"message": "KiCAD is running",
|
||||
"backend": "ipc",
|
||||
"realtime_sync": true,
|
||||
"ipc_connected": true
|
||||
}
|
||||
```
|
||||
|
||||
@@ -104,10 +107,26 @@ Launch KiCAD UI, optionally with a project file.
|
||||
"launched": true,
|
||||
"message": "KiCAD launched successfully",
|
||||
"project": "/tmp/mcp_demo/New_Project.kicad_pcb",
|
||||
"processes": [...]
|
||||
"processes": [...],
|
||||
"backend": "ipc",
|
||||
"realtime_sync": true,
|
||||
"ipc_connected": true
|
||||
}
|
||||
```
|
||||
|
||||
If IPC is not enabled, no board is open, or the IPC connection is otherwise not
|
||||
available yet, these status fields report `backend: "swig"`,
|
||||
`realtime_sync: false`, and `ipc_connected: false`.
|
||||
|
||||
### IPC Reconnect After Launch
|
||||
|
||||
When the MCP server starts before KiCAD, it may initially fall back to the SWIG
|
||||
backend. `launch_kicad_ui` and `check_kicad_ui` now refresh the live backend
|
||||
status after KiCAD is running, and normal IPC-capable board tools retry IPC on
|
||||
their next call. Agents do not need to switch to special `ipc_*` tools; they can
|
||||
retry standard tools such as `get_board_info`, `get_layer_list`,
|
||||
`get_component_list`, `get_nets_list`, or `query_traces`.
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Workflow Examples
|
||||
@@ -383,16 +402,15 @@ await callKicadScript("launch_kicad_ui", {
|
||||
|
||||
- **Window Management:** Bring KiCAD to front, minimize/maximize
|
||||
- **Multi-Instance:** Handle multiple KiCAD instances
|
||||
- **IPC Integration:** Seamless integration with IPC backend
|
||||
- **Status Notifications:** Push notifications when KiCAD state changes
|
||||
- **Auto-Close:** Option to close KiCAD after operations complete
|
||||
|
||||
### IPC Mode (Coming Weeks 2-3)
|
||||
### IPC Mode
|
||||
|
||||
When IPC backend is fully implemented:
|
||||
When KiCAD is running with IPC enabled:
|
||||
|
||||
```
|
||||
KiCAD runs in background → MCP connects via IPC → Real-time updates
|
||||
KiCAD runs in background → MCP connects or reconnects via IPC → Real-time updates
|
||||
No file reloading needed! Changes appear instantly.
|
||||
```
|
||||
|
||||
|
||||
@@ -2,15 +2,19 @@
|
||||
|
||||
This document explains how to see changes made by the MCP server in the KiCAD UI in real-time or near-real-time.
|
||||
|
||||
## Current Status (Week 1 - SWIG Backend)
|
||||
## Current Status
|
||||
|
||||
**Active Backend:** SWIG (legacy pcbnew Python API)
|
||||
**Real-time Updates:** Not available yet
|
||||
**IPC Backend:** Skeleton implemented, operations coming in Weeks 2-3
|
||||
**Active Backend:** Hybrid SWIG/IPC
|
||||
|
||||
**Real-time Updates:** Available for IPC-backed commands when KiCAD IPC is connected
|
||||
|
||||
**SWIG Fallback:** File-based commands still require KiCAD to reload from disk
|
||||
|
||||
**IPC Re-detect:** IPC-enabled tools detect at runtime if IPC is available again and switch from SWIG to IPC.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Best Current Workflow (SWIG + Manual Reload)
|
||||
## 🎯 Best Current Workflow (Hybrid IPC + SWIG)
|
||||
|
||||
### Setup
|
||||
|
||||
@@ -24,10 +28,11 @@ This document explains how to see changes made by the MCP server in the KiCAD UI
|
||||
- Example: Add board outline, mounting holes, etc.
|
||||
- Each operation saves the file automatically
|
||||
|
||||
3. **Reload in KiCAD UI**
|
||||
- **Option A (Automatic):** KiCAD 8.0+ detects file changes and shows a reload prompt
|
||||
- **Option B (Manual):** File → Revert to reload from disk
|
||||
- **Keyboard shortcut:** None by default (but you can assign one)
|
||||
3. **Check whether reload is needed**
|
||||
- If the MCP response reports `_backend: "ipc"` and `_realtime: true`, the change should appear in KiCAD immediately.
|
||||
- If the MCP response reports `_backend: "swig"` or `_realtime: false`, reload the board from disk.
|
||||
- **Option A (Automatic):** KiCAD 8.0+ detects file changes and shows a reload prompt.
|
||||
- **Option B (Manual):** File → Revert to reload from disk.
|
||||
|
||||
### Workflow Example
|
||||
|
||||
@@ -57,34 +62,51 @@ This document explains how to see changes made by the MCP server in the KiCAD UI
|
||||
|
||||
---
|
||||
|
||||
## 🔮 Future: IPC Backend (Weeks 2-3)
|
||||
## IPC Backend: Real-Time Updates (Experimental)
|
||||
|
||||
When fully implemented, the IPC backend will provide **true real-time updates**:
|
||||
When KiCAD is running with the IPC API enabled, supported MCP board tools can
|
||||
use the IPC backend for **true real-time UI updates** instead of relying on
|
||||
file save/reload.
|
||||
|
||||
### How It Will Work
|
||||
### How It Works
|
||||
|
||||
```
|
||||
Claude MCP → IPC Socket → Running KiCAD → Instant UI Update
|
||||
```text
|
||||
Agent → MCP → IPC connection → Running KiCAD → Instant UI Update
|
||||
```
|
||||
|
||||
**No file reloading required** - changes appear as you make them!
|
||||
**No file reloading is required** for commands that successfully use IPC. Tool
|
||||
responses include `_backend: "ipc"` and `_realtime: true` when the IPC path was
|
||||
used. If IPC is unavailable, the server falls back to SWIG and the manual reload
|
||||
workflow still applies. If IPC becomes available during the session,
|
||||
IPC-capable tools can reconnect and use IPC without restarting the MCP server.
|
||||
|
||||
### IPC Setup (When Available)
|
||||
### IPC Setup
|
||||
|
||||
1. **Enable IPC in KiCAD**
|
||||
- Preferences → Advanced Preferences
|
||||
- Search for "IPC"
|
||||
- Enable: "Enable IPC API Server"
|
||||
- Restart KiCAD
|
||||
1. Enable IPC in KiCAD:
|
||||
- Preferences → Plugins → Enable IPC API Server
|
||||
- Restart KiCAD if required
|
||||
|
||||
2. **Install kicad-python** (Already installed ✓)
|
||||
2. Install `kicad-python`:
|
||||
|
||||
```bash
|
||||
pip install kicad-python
|
||||
```
|
||||
|
||||
3. **Configure MCP Server**
|
||||
Add to your MCP config:
|
||||
|
||||
The default `auto` backend mode is recommended when you want SWIG fallback
|
||||
plus runtime reconnect. To make the setting explicit, add:
|
||||
|
||||
```json
|
||||
{
|
||||
"env": {
|
||||
"KICAD_BACKEND": "auto"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Use strict `ipc` mode only when you want startup to fail if IPC is not
|
||||
available:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -94,20 +116,23 @@ Claude MCP → IPC Socket → Running KiCAD → Instant UI Update
|
||||
}
|
||||
```
|
||||
|
||||
4. **Start KiCAD first, then use MCP**
|
||||
- Changes will appear in real-time
|
||||
- No manual reloading needed
|
||||
4. Start KiCAD and open a board, or use `launch_kicad_ui`.
|
||||
|
||||
The MCP server **can** start before KiCAD. In `auto` backend mode, IPC-capable
|
||||
board tools retry IPC at runtime after KiCAD is available, so agents can keep
|
||||
using standard tools such as `get_board_info`, `get_layer_list`,
|
||||
`get_component_list`, `get_nets_list`, and `query_traces`.
|
||||
|
||||
### Current IPC Status
|
||||
|
||||
| Feature | Status |
|
||||
| -------------------- | ----------- |
|
||||
| Connection to KiCAD | ✅ Working |
|
||||
| Version checking | ✅ Working |
|
||||
| Project operations | ⏳ Week 2-3 |
|
||||
| Board operations | ⏳ Week 2-3 |
|
||||
| Component operations | ⏳ Week 2-3 |
|
||||
| Routing operations | ⏳ Week 2-3 |
|
||||
| ------------------------ | ------------------------------------------------------------- |
|
||||
| Connection to KiCAD | Working when KiCAD IPC is enabled |
|
||||
| Board operations | Partially implemented via IPC |
|
||||
| Component operations | Partially implemented / hybrid |
|
||||
| Routing operations | Partially implemented via IPC |
|
||||
| SWIG fallback | Used automatically in `auto` mode when IPC is unavailable |
|
||||
| Runtime reconnect to IPC | Used automatically in `auto` mode for IPC-capable board tools |
|
||||
|
||||
---
|
||||
|
||||
@@ -153,7 +178,7 @@ The MCP server auto-saves after each operation, so changes are immediately avail
|
||||
For complex changes (multiple components, routing, etc.):
|
||||
|
||||
1. Make the change
|
||||
2. Reload in KiCAD
|
||||
2. Confirm the change in KiCAD; reload only if the response used SWIG
|
||||
3. Verify it looks correct
|
||||
4. Proceed with next change
|
||||
|
||||
@@ -171,6 +196,15 @@ For complex changes (multiple components, routing, etc.):
|
||||
**Cause:** MCP operation may have failed
|
||||
**Solution:** Check the MCP response for success: true
|
||||
|
||||
### Changes Still Require Reload
|
||||
|
||||
**Cause:** The tool response reported `_backend: "swig"` or `_realtime: false`.
|
||||
SWIG-backed commands write files directly and still require KiCAD to reload the board from disk.
|
||||
|
||||
**Solution:** Ensure KiCAD is running with IPC enabled and a board is open, then
|
||||
retry the IPC-capable board tool. If IPC reconnect succeeds, the response will
|
||||
report `_backend: "ipc"` and `_realtime: true`.
|
||||
|
||||
### File is Locked
|
||||
|
||||
**Cause:** KiCAD has the file open exclusively
|
||||
@@ -181,13 +215,5 @@ For complex changes (multiple components, routing, etc.):
|
||||
|
||||
---
|
||||
|
||||
## 📅 Roadmap
|
||||
|
||||
**Current (Week 1):** SWIG backend with manual reload
|
||||
**Week 2-3:** IPC backend implementation
|
||||
**Week 4+:** Real-time collaboration features
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2025-10-26
|
||||
**Version:** 2.0.0-alpha.1
|
||||
|
||||
@@ -45,10 +45,12 @@ If the automated setup fails, continue with the manual troubleshooting below.
|
||||
2. **Test pcbnew import manually:**
|
||||
|
||||
```powershell
|
||||
& "C:\Program Files\KiCad\9.0\bin\python.exe" -c "import pcbnew; print(pcbnew.GetBuildVersion())"
|
||||
& "C:\Program Files\KiCad\10.0\bin\python.exe" -c "import pcbnew; print(pcbnew.GetBuildVersion())"
|
||||
```
|
||||
|
||||
**Expected:** Prints KiCAD version like `9.0.0`
|
||||
Replace `10.0` with your installed KiCAD version if needed.
|
||||
|
||||
**Expected:** Prints KiCAD version like `10.0.0`
|
||||
|
||||
**If it fails:**
|
||||
- KiCAD's Python module isn't installed
|
||||
@@ -61,7 +63,7 @@ If the automated setup fails, continue with the manual troubleshooting below.
|
||||
"mcpServers": {
|
||||
"kicad": {
|
||||
"env": {
|
||||
"PYTHONPATH": "C:\\Program Files\\KiCad\\9.0\\lib\\python3\\dist-packages"
|
||||
"PYTHONPATH": "C:\\Program Files\\KiCad\\10.0\\lib\\python3\\dist-packages"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,25 +76,37 @@ If the automated setup fails, continue with the manual troubleshooting below.
|
||||
|
||||
**Symptom:** Log shows "No KiCAD installations found"
|
||||
|
||||
The server checks common Windows install locations, including both machine-wide
|
||||
and per-user KiCAD installs:
|
||||
|
||||
- `%LOCALAPPDATA%\Programs\KiCad`
|
||||
- `C:\Program Files\KiCad`
|
||||
- `C:\Program Files (x86)\KiCad`
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. **Check if KiCAD is installed:**
|
||||
|
||||
```powershell
|
||||
Test-Path "C:\Program Files\KiCad\9.0"
|
||||
Test-Path "C:\Program Files\KiCad\10.0"
|
||||
Test-Path "$env:LOCALAPPDATA\Programs\KiCad\10.0"
|
||||
```
|
||||
|
||||
Replace `10.0` with your installed KiCAD version if needed.
|
||||
|
||||
2. **If KiCAD is installed elsewhere:**
|
||||
- Find your KiCAD installation directory
|
||||
- Update PYTHONPATH in config to match your installation
|
||||
- Example for version 8.0:
|
||||
- Set `KICAD_PYTHON` to the bundled `python.exe`
|
||||
- Update `PYTHONPATH` in config to match your installation if needed
|
||||
- Example for a per-user 10.0 install:
|
||||
```
|
||||
"PYTHONPATH": "C:\\Program Files\\KiCad\\8.0\\lib\\python3\\dist-packages"
|
||||
"KICAD_PYTHON": "C:\\Users\\YourName\\AppData\\Local\\Programs\\KiCad\\10.0\\bin\\python.exe",
|
||||
"PYTHONPATH": "C:\\Users\\YourName\\AppData\\Local\\Programs\\KiCad\\10.0\\lib\\python3\\dist-packages"
|
||||
```
|
||||
|
||||
3. **If KiCAD is not installed:**
|
||||
- Download from https://www.kicad.org/download/windows/
|
||||
- Install version 9.0 or higher
|
||||
- Install KiCAD 9.0 or higher
|
||||
- Use default installation path
|
||||
|
||||
---
|
||||
@@ -162,7 +176,7 @@ If the automated setup fails, continue with the manual troubleshooting below.
|
||||
1. **Install with KiCAD's Python:**
|
||||
|
||||
```powershell
|
||||
& "C:\Program Files\KiCad\9.0\bin\python.exe" -m pip install -r requirements.txt
|
||||
& "C:\Program Files\KiCad\10.0\bin\python.exe" -m pip install -r requirements.txt
|
||||
```
|
||||
|
||||
2. **If pip is not available:**
|
||||
@@ -172,10 +186,10 @@ If the automated setup fails, continue with the manual troubleshooting below.
|
||||
Invoke-WebRequest -Uri https://bootstrap.pypa.io/get-pip.py -OutFile get-pip.py
|
||||
|
||||
# Install pip
|
||||
& "C:\Program Files\KiCad\9.0\bin\python.exe" get-pip.py
|
||||
& "C:\Program Files\KiCad\10.0\bin\python.exe" get-pip.py
|
||||
|
||||
# Then install requirements
|
||||
& "C:\Program Files\KiCad\9.0\bin\python.exe" -m pip install -r requirements.txt
|
||||
& "C:\Program Files\KiCad\10.0\bin\python.exe" -m pip install -r requirements.txt
|
||||
```
|
||||
|
||||
---
|
||||
@@ -232,7 +246,8 @@ If the automated setup fails, continue with the manual troubleshooting below.
|
||||
|
||||
**Solution:**
|
||||
|
||||
KiCAD MCP requires Python 3.10+. KiCAD 9.0 includes Python 3.11, which is perfect.
|
||||
KiCAD MCP requires Python 3.10+. KiCAD 10.0 includes a compatible bundled Python,
|
||||
and KiCAD 9.0+ is supported.
|
||||
|
||||
**Always use KiCAD's bundled Python:**
|
||||
|
||||
@@ -240,7 +255,7 @@ KiCAD MCP requires Python 3.10+. KiCAD 9.0 includes Python 3.11, which is perfec
|
||||
{
|
||||
"mcpServers": {
|
||||
"kicad": {
|
||||
"command": "C:\\Program Files\\KiCad\\9.0\\bin\\python.exe",
|
||||
"command": "C:\\Program Files\\KiCad\\10.0\\bin\\python.exe",
|
||||
"args": ["C:\\Users\\YourName\\KiCAD-MCP-Server\\python\\kicad_interface.py"]
|
||||
}
|
||||
}
|
||||
@@ -264,7 +279,7 @@ Config location: `%APPDATA%\Claude\claude_desktop_config.json`
|
||||
"command": "node",
|
||||
"args": ["C:\\Users\\YourName\\KiCAD-MCP-Server\\dist\\index.js"],
|
||||
"env": {
|
||||
"PYTHONPATH": "C:\\Program Files\\KiCad\\9.0\\lib\\python3\\dist-packages",
|
||||
"PYTHONPATH": "C:\\Program Files\\KiCad\\10.0\\lib\\python3\\dist-packages",
|
||||
"NODE_ENV": "production",
|
||||
"LOG_LEVEL": "info"
|
||||
}
|
||||
@@ -284,7 +299,7 @@ Config location: `%APPDATA%\Code\User\globalStorage\saoudrizwan.claude-dev\setti
|
||||
"command": "node",
|
||||
"args": ["C:\\Users\\YourName\\KiCAD-MCP-Server\\dist\\index.js"],
|
||||
"env": {
|
||||
"PYTHONPATH": "C:\\Program Files\\KiCad\\9.0\\lib\\python3\\dist-packages"
|
||||
"PYTHONPATH": "C:\\Program Files\\KiCad\\10.0\\lib\\python3\\dist-packages"
|
||||
},
|
||||
"description": "KiCAD PCB Design Assistant"
|
||||
}
|
||||
@@ -300,10 +315,10 @@ If Node.js issues persist, run Python directly:
|
||||
{
|
||||
"mcpServers": {
|
||||
"kicad": {
|
||||
"command": "C:\\Program Files\\KiCad\\9.0\\bin\\python.exe",
|
||||
"command": "C:\\Program Files\\KiCad\\10.0\\bin\\python.exe",
|
||||
"args": ["C:\\Users\\YourName\\KiCAD-MCP-Server\\python\\kicad_interface.py"],
|
||||
"env": {
|
||||
"PYTHONPATH": "C:\\Program Files\\KiCad\\9.0\\lib\\python3\\dist-packages"
|
||||
"PYTHONPATH": "C:\\Program Files\\KiCad\\10.0\\lib\\python3\\dist-packages"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -317,7 +332,7 @@ If Node.js issues persist, run Python directly:
|
||||
### Test 1: Verify KiCAD Python
|
||||
|
||||
```powershell
|
||||
& "C:\Program Files\KiCad\9.0\bin\python.exe" -c @"
|
||||
& "C:\Program Files\KiCad\10.0\bin\python.exe" -c @"
|
||||
import sys
|
||||
print(f'Python version: {sys.version}')
|
||||
import pcbnew
|
||||
@@ -330,7 +345,7 @@ Expected output:
|
||||
|
||||
```
|
||||
Python version: 3.11.x ...
|
||||
pcbnew version: 9.0.0
|
||||
pcbnew version: 10.0.0
|
||||
SUCCESS!
|
||||
```
|
||||
|
||||
@@ -353,7 +368,7 @@ Test-Path .\dist\index.js # Should output: True
|
||||
### Test 4: Run Server Manually
|
||||
|
||||
```powershell
|
||||
$env:PYTHONPATH = "C:\Program Files\KiCad\9.0\lib\python3\dist-packages"
|
||||
$env:PYTHONPATH = "C:\Program Files\KiCad\10.0\lib\python3\dist-packages"
|
||||
node .\dist\index.js
|
||||
```
|
||||
|
||||
@@ -390,20 +405,20 @@ Add to your MCP config:
|
||||
### Check Python sys.path
|
||||
|
||||
```powershell
|
||||
& "C:\Program Files\KiCad\9.0\bin\python.exe" -c @"
|
||||
& "C:\Program Files\KiCad\10.0\bin\python.exe" -c @"
|
||||
import sys
|
||||
for path in sys.path:
|
||||
print(path)
|
||||
"@
|
||||
```
|
||||
|
||||
Should include: `C:\Program Files\KiCad\9.0\lib\python3\dist-packages`
|
||||
Should include: `C:\Program Files\KiCad\10.0\lib\python3\dist-packages`
|
||||
|
||||
### Test MCP Communication
|
||||
|
||||
```powershell
|
||||
# Start server
|
||||
$env:PYTHONPATH = "C:\Program Files\KiCad\9.0\lib\python3\dist-packages"
|
||||
$env:PYTHONPATH = "C:\Program Files\KiCad\10.0\lib\python3\dist-packages"
|
||||
$process = Start-Process -FilePath "node" -ArgumentList ".\dist\index.js" -NoNewWindow -PassThru
|
||||
|
||||
# Wait 3 seconds
|
||||
@@ -474,7 +489,8 @@ If none of the above solutions work:
|
||||
|
||||
When everything works, you should have:
|
||||
|
||||
- [ ] KiCAD 9.0+ installed at `C:\Program Files\KiCad\9.0`
|
||||
- [ ] KiCAD 9.0 or higher installed under a versioned KiCAD directory such as
|
||||
`C:\Program Files\KiCad\10.0` or `%LOCALAPPDATA%\Programs\KiCad\10.0`
|
||||
- [ ] Node.js 18+ installed and in PATH
|
||||
- [ ] Python can import pcbnew successfully
|
||||
- [ ] `npm run build` completes without errors
|
||||
|
||||
@@ -371,10 +371,11 @@ class IPCBoardAPI(BoardAPI):
|
||||
# Check if on Edge.Cuts layer
|
||||
bbox = board.get_item_bounding_box(shape)
|
||||
if bbox:
|
||||
min_x = min(min_x, bbox.min.x)
|
||||
min_y = min(min_y, bbox.min.y)
|
||||
max_x = max(max_x, bbox.max.x)
|
||||
max_y = max(max_y, bbox.max.y)
|
||||
left, top, right, bottom = self._get_box2_extents(bbox)
|
||||
min_x = min(min_x, left)
|
||||
min_y = min(min_y, top)
|
||||
max_x = max(max_x, right)
|
||||
max_y = max(max_y, bottom)
|
||||
|
||||
if min_x == float("inf"):
|
||||
return {"width": 0, "height": 0, "unit": "mm"}
|
||||
@@ -385,6 +386,21 @@ class IPCBoardAPI(BoardAPI):
|
||||
logger.error(f"Failed to get board size: {e}")
|
||||
return {"width": 0, "height": 0, "unit": "mm", "error": str(e)}
|
||||
|
||||
@staticmethod
|
||||
def _get_box2_extents(bbox: Any) -> tuple[float, float, float, float]:
|
||||
"""Return left/top/right/bottom for kipy Box2 wrappers across versions."""
|
||||
if hasattr(bbox, "min") and hasattr(bbox, "max"):
|
||||
return bbox.min.x, bbox.min.y, bbox.max.x, bbox.max.y
|
||||
|
||||
if hasattr(bbox, "pos") and hasattr(bbox, "size"):
|
||||
x1 = bbox.pos.x
|
||||
y1 = bbox.pos.y
|
||||
x2 = x1 + bbox.size.x
|
||||
y2 = y1 + bbox.size.y
|
||||
return min(x1, x2), min(y1, y2), max(x1, x2), max(y1, y2)
|
||||
|
||||
raise AttributeError("Unsupported Box2 shape: expected min/max or pos/size")
|
||||
|
||||
def add_layer(self, layer_name: str, layer_type: str) -> bool:
|
||||
"""Add layer to the board (layers are typically predefined in KiCAD)."""
|
||||
logger.warning("Layer management via IPC is limited - layers are predefined")
|
||||
|
||||
@@ -496,6 +496,7 @@ class KiCADInterface:
|
||||
"add_via": "_ipc_add_via",
|
||||
"add_net": "_ipc_add_net",
|
||||
"delete_trace": "_ipc_delete_trace",
|
||||
"query_traces": "_ipc_query_traces",
|
||||
"get_nets_list": "_ipc_get_nets_list",
|
||||
# Zone commands
|
||||
"add_copper_pour": "_ipc_add_copper_pour",
|
||||
@@ -519,12 +520,99 @@ class KiCADInterface:
|
||||
"save_project": "_ipc_save_project",
|
||||
}
|
||||
|
||||
# Commands that are implemented by the explicit IPC command handlers in
|
||||
# command_routes, rather than by the generic IPC_CAPABLE_COMMANDS fast path.
|
||||
IPC_DIRECT_COMMANDS = {
|
||||
"ipc_add_track",
|
||||
"ipc_add_via",
|
||||
"ipc_add_text",
|
||||
"ipc_list_components",
|
||||
"ipc_get_tracks",
|
||||
"ipc_get_vias",
|
||||
"ipc_save_board",
|
||||
}
|
||||
|
||||
def _refresh_ipc_board_api(self) -> bool:
|
||||
"""Refresh the IPC board API after KiCAD or a board becomes available."""
|
||||
ipc_backend = getattr(self, "ipc_backend", None)
|
||||
if not ipc_backend or not ipc_backend.is_connected():
|
||||
self.ipc_board_api = None
|
||||
return False
|
||||
|
||||
try:
|
||||
self.ipc_board_api = ipc_backend.get_board()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"Connected to KiCAD IPC, but no board API is available yet: {e}")
|
||||
self.ipc_board_api = None
|
||||
return False
|
||||
|
||||
def _try_enable_ipc_backend(self, force: bool = False) -> bool:
|
||||
"""Try to switch an already-running interface to IPC when KiCAD is available."""
|
||||
if KICAD_BACKEND == "swig":
|
||||
return False
|
||||
|
||||
ipc_backend = getattr(self, "ipc_backend", None)
|
||||
if self.use_ipc and ipc_backend and ipc_backend.is_connected():
|
||||
self._refresh_ipc_board_api()
|
||||
return True
|
||||
|
||||
if not force and not KiCADProcessManager.is_running():
|
||||
return False
|
||||
|
||||
try:
|
||||
from kicad_api.ipc_backend import IPCBackend
|
||||
|
||||
backend = ipc_backend or IPCBackend()
|
||||
if not backend.is_connected():
|
||||
backend.connect()
|
||||
|
||||
self.ipc_backend = backend
|
||||
self.use_ipc = True
|
||||
self._refresh_ipc_board_api()
|
||||
logger.info("Switched to IPC backend after KiCAD became available")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.info(f"Runtime IPC connection not available: {e}")
|
||||
return False
|
||||
|
||||
def _backend_status(self) -> Dict[str, Any]:
|
||||
"""Return backend status fields for command responses."""
|
||||
ipc_backend = getattr(self, "ipc_backend", None)
|
||||
ipc_connected = ipc_backend.is_connected() if ipc_backend else False
|
||||
return {
|
||||
"backend": "ipc" if self.use_ipc and ipc_connected else "swig",
|
||||
"realtime_sync": self.use_ipc and ipc_connected,
|
||||
"ipc_connected": ipc_connected,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _normalize_ipc_layer_name(layer: Any) -> str:
|
||||
"""Convert KiCad IPC layer enum strings to common layer names."""
|
||||
layer_name = str(layer)
|
||||
if layer_name.startswith("BL_"):
|
||||
return layer_name[3:].replace("_", ".")
|
||||
return layer_name
|
||||
|
||||
def _result_backend_for_command(self, command: str, result: Dict[str, Any]) -> str:
|
||||
"""Return the backend label for a command result."""
|
||||
if command in {"get_backend_info", "check_kicad_ui", "launch_kicad_ui"}:
|
||||
return result.get("backend", "ipc" if self.use_ipc else "swig")
|
||||
|
||||
if command in self.IPC_DIRECT_COMMANDS:
|
||||
return "ipc" if self.use_ipc else "unavailable"
|
||||
|
||||
return "swig"
|
||||
|
||||
def handle_command(self, command: str, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Route command to appropriate handler, preferring IPC when available"""
|
||||
logger.info(f"Handling command: {command}")
|
||||
logger.debug(f"Command parameters: {params}")
|
||||
|
||||
try:
|
||||
if command in self.IPC_CAPABLE_COMMANDS:
|
||||
self._try_enable_ipc_backend()
|
||||
|
||||
# Check if we can use IPC for this command (real-time UI sync)
|
||||
if self.use_ipc and self.ipc_board_api and command in self.IPC_CAPABLE_COMMANDS:
|
||||
ipc_handler_name = self.IPC_CAPABLE_COMMANDS[command]
|
||||
@@ -558,8 +646,11 @@ class KiCADInterface:
|
||||
|
||||
# Add backend indicator
|
||||
if isinstance(result, dict):
|
||||
result["_backend"] = "swig"
|
||||
result["_realtime"] = False
|
||||
backend = self._result_backend_for_command(command, result)
|
||||
result["_backend"] = backend
|
||||
result["_realtime"] = bool(
|
||||
backend == "ipc" and result.get("realtime", self.use_ipc)
|
||||
)
|
||||
|
||||
# Update board reference if command was successful
|
||||
if result.get("success", False):
|
||||
@@ -5018,14 +5109,21 @@ class KiCADInterface:
|
||||
logger.info("Checking if KiCAD UI is running")
|
||||
try:
|
||||
manager = KiCADProcessManager()
|
||||
# `processes` is the single source of truth (from #173) so
|
||||
# `running` can't disagree with it; and if KiCAD is up, opportunistically
|
||||
# (re)connect the IPC backend (#140) so a session that started before
|
||||
# KiCAD launched can fall up from SWIG to IPC.
|
||||
processes = manager.get_process_info()
|
||||
is_running = len(processes) > 0
|
||||
if is_running:
|
||||
self._try_enable_ipc_backend()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"running": is_running,
|
||||
"processes": processes,
|
||||
"message": "KiCAD is running" if is_running else "KiCAD is not running",
|
||||
**self._backend_status(),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking KiCAD UI status: {str(e)}")
|
||||
@@ -5044,8 +5142,10 @@ class KiCADInterface:
|
||||
path_obj = Path(project_path) if project_path else None
|
||||
|
||||
result = check_and_launch_kicad(path_obj, auto_launch)
|
||||
if result.get("running"):
|
||||
self._try_enable_ipc_backend(force=True)
|
||||
|
||||
return {"success": True, **result}
|
||||
return {"success": True, **result, **self._backend_status()}
|
||||
except Exception as e:
|
||||
logger.error(f"Error launching KiCAD UI: {str(e)}")
|
||||
return {"success": False, "message": str(e)}
|
||||
@@ -5581,6 +5681,85 @@ print("ok")
|
||||
logger.info("delete_trace: Falling back to SWIG (IPC doesn't support trace deletion)")
|
||||
return self.routing_commands.delete_trace(params)
|
||||
|
||||
def _ipc_query_traces(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""IPC handler for query_traces - reads traces from the live KiCAD board."""
|
||||
try:
|
||||
net_name = params.get("net")
|
||||
layer_filter = params.get("layer")
|
||||
bbox = params.get("boundingBox")
|
||||
include_vias = params.get("includeVias", False)
|
||||
|
||||
def point_in_bbox(point: Dict[str, Any]) -> bool:
|
||||
if not bbox:
|
||||
return True
|
||||
unit_scale = 25.4 if bbox.get("unit", "mm") == "inch" else 1.0
|
||||
x1 = bbox.get("x1", 0) * unit_scale
|
||||
y1 = bbox.get("y1", 0) * unit_scale
|
||||
x2 = bbox.get("x2", 0) * unit_scale
|
||||
y2 = bbox.get("y2", 0) * unit_scale
|
||||
low_x, high_x = sorted((x1, x2))
|
||||
low_y, high_y = sorted((y1, y2))
|
||||
return low_x <= point.get("x", 0) <= high_x and low_y <= point.get("y", 0) <= high_y
|
||||
|
||||
traces = []
|
||||
for track in self.ipc_board_api.get_tracks():
|
||||
if net_name and track.get("net") != net_name:
|
||||
continue
|
||||
|
||||
layer = self._normalize_ipc_layer_name(track.get("layer", ""))
|
||||
if layer_filter and layer != layer_filter:
|
||||
continue
|
||||
|
||||
start = track.get("start", {})
|
||||
end = track.get("end", {})
|
||||
if bbox and not (point_in_bbox(start) or point_in_bbox(end)):
|
||||
continue
|
||||
|
||||
start_with_unit = {**start, "unit": "mm"}
|
||||
end_with_unit = {**end, "unit": "mm"}
|
||||
dx = end.get("x", 0) - start.get("x", 0)
|
||||
dy = end.get("y", 0) - start.get("y", 0)
|
||||
traces.append(
|
||||
{
|
||||
"uuid": track.get("id", ""),
|
||||
"net": track.get("net", ""),
|
||||
"netCode": track.get("netCode", 0),
|
||||
"layer": layer,
|
||||
"width": track.get("width", 0),
|
||||
"start": start_with_unit,
|
||||
"end": end_with_unit,
|
||||
"length": (dx**2 + dy**2) ** 0.5,
|
||||
}
|
||||
)
|
||||
|
||||
result = {"success": True, "traceCount": len(traces), "traces": traces}
|
||||
|
||||
if include_vias:
|
||||
vias = []
|
||||
for via in self.ipc_board_api.get_vias():
|
||||
if net_name and via.get("net") != net_name:
|
||||
continue
|
||||
position = via.get("position", {})
|
||||
if bbox and not point_in_bbox(position):
|
||||
continue
|
||||
vias.append(
|
||||
{
|
||||
"uuid": via.get("id", ""),
|
||||
"position": {**position, "unit": "mm"},
|
||||
"net": via.get("net", ""),
|
||||
"netCode": via.get("netCode", 0),
|
||||
"diameter": via.get("diameter", 0),
|
||||
"drill": via.get("drill", 0),
|
||||
}
|
||||
)
|
||||
result["viaCount"] = len(vias)
|
||||
result["vias"] = vias
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"IPC query_traces error: {e}")
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _ipc_get_nets_list(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""IPC handler for get_nets_list - gets nets with real-time data"""
|
||||
try:
|
||||
@@ -5777,15 +5956,17 @@ print("ok")
|
||||
|
||||
def _handle_get_backend_info(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get information about the current backend"""
|
||||
if KiCADProcessManager.is_running():
|
||||
self._try_enable_ipc_backend()
|
||||
status = self._backend_status()
|
||||
ipc_backend = getattr(self, "ipc_backend", None)
|
||||
return {
|
||||
"success": True,
|
||||
"backend": "ipc" if self.use_ipc else "swig",
|
||||
"realtime_sync": self.use_ipc,
|
||||
"ipc_connected": (self.ipc_backend.is_connected() if self.ipc_backend else False),
|
||||
"version": self.ipc_backend.get_version() if self.ipc_backend else "N/A",
|
||||
**status,
|
||||
"version": ipc_backend.get_version() if ipc_backend else "N/A",
|
||||
"message": (
|
||||
"Using IPC backend with real-time UI sync"
|
||||
if self.use_ipc
|
||||
if status["backend"] == "ipc"
|
||||
else "Using SWIG backend (requires manual reload)"
|
||||
),
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ 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 } from "fs";
|
||||
import { existsSync, readdirSync } from "fs";
|
||||
import { join, dirname } from "path";
|
||||
import { logger } from "./logger.js";
|
||||
|
||||
@@ -40,9 +40,41 @@ 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)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the Python executable to use
|
||||
* Prioritizes virtual environment if available, falls back to system Python
|
||||
* 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";
|
||||
@@ -73,12 +105,13 @@ function findPythonExecutable(scriptPath: string): string {
|
||||
|
||||
// Platform-specific KiCAD bundled Python detection
|
||||
if (isWindows) {
|
||||
// Windows: Always prefer KiCAD's bundled Python (pcbnew.pyd is compiled for it)
|
||||
const kicadPython = "C:\\Program Files\\KiCad\\9.0\\bin\\python.exe";
|
||||
// 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"];
|
||||
@@ -259,10 +292,12 @@ export class KiCADMcpServer {
|
||||
// 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) {
|
||||
@@ -299,6 +334,7 @@ export class KiCADMcpServer {
|
||||
|
||||
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");
|
||||
@@ -325,7 +361,7 @@ export class KiCADMcpServer {
|
||||
}
|
||||
|
||||
// Try to test pcbnew import (quick validation)
|
||||
if (existsSync(pythonExe) && existsSync(this.kicadScriptPath)) {
|
||||
if (pythonExecutableAvailable && existsSync(this.kicadScriptPath)) {
|
||||
logger.info("Validating pcbnew module access...");
|
||||
|
||||
const testCommand = `"${pythonExe}" -c "import pcbnew; print('OK')"`;
|
||||
|
||||
466
tests/test_backend_metadata.py
Normal file
466
tests/test_backend_metadata.py
Normal file
@@ -0,0 +1,466 @@
|
||||
"""Tests for backend metadata added by KiCADInterface.handle_command."""
|
||||
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "python"))
|
||||
|
||||
|
||||
def _make_iface(command_routes, use_ipc=False):
|
||||
from kicad_interface import KiCADInterface
|
||||
|
||||
iface = KiCADInterface.__new__(KiCADInterface)
|
||||
iface.use_ipc = use_ipc
|
||||
iface.ipc_backend = None
|
||||
iface.ipc_board_api = None
|
||||
iface.command_routes = command_routes
|
||||
return iface
|
||||
|
||||
|
||||
class _FakeIPCBoardAPI:
|
||||
def get_size(self):
|
||||
return {"width": 10, "height": 20, "unit": "mm"}
|
||||
|
||||
def list_components(self):
|
||||
return []
|
||||
|
||||
def get_tracks(self):
|
||||
return [
|
||||
{
|
||||
"id": "track-1",
|
||||
"start": {"x": 0, "y": 0},
|
||||
"end": {"x": 3, "y": 4},
|
||||
"width": 0.25,
|
||||
"layer": "BL_F_Cu",
|
||||
"net": "N$1",
|
||||
}
|
||||
]
|
||||
|
||||
def get_vias(self):
|
||||
return []
|
||||
|
||||
def get_nets(self):
|
||||
return [{"name": "N$1", "code": 1}]
|
||||
|
||||
def get_enabled_layers(self):
|
||||
return ["F.Cu", "B.Cu"]
|
||||
|
||||
|
||||
class _FakeIPCBackend:
|
||||
def __init__(self):
|
||||
self.connected = False
|
||||
|
||||
def connect(self):
|
||||
self.connected = True
|
||||
return True
|
||||
|
||||
def is_connected(self):
|
||||
return self.connected
|
||||
|
||||
def get_board(self):
|
||||
return _FakeIPCBoardAPI()
|
||||
|
||||
def get_version(self):
|
||||
return "9.0-test"
|
||||
|
||||
|
||||
class _ConnectShouldNotBeCalledIPCBackend(_FakeIPCBackend):
|
||||
def connect(self):
|
||||
raise AssertionError("IPC reconnect should not be attempted")
|
||||
|
||||
|
||||
class _FailingConnectIPCBackend(_FakeIPCBackend):
|
||||
def connect(self):
|
||||
raise RuntimeError("IPC unavailable")
|
||||
|
||||
|
||||
class _NoBoardIPCBackend(_FakeIPCBackend):
|
||||
def get_board(self):
|
||||
raise RuntimeError("No board open")
|
||||
|
||||
|
||||
class _FilteringIPCBoardAPI(_FakeIPCBoardAPI):
|
||||
def get_tracks(self):
|
||||
return [
|
||||
{
|
||||
"id": "track-1",
|
||||
"start": {"x": 0, "y": 0},
|
||||
"end": {"x": 3, "y": 4},
|
||||
"width": 0.25,
|
||||
"layer": "BL_F_Cu",
|
||||
"net": "N$1",
|
||||
"netCode": 1,
|
||||
},
|
||||
{
|
||||
"id": "track-2",
|
||||
"start": {"x": 10, "y": 10},
|
||||
"end": {"x": 11, "y": 11},
|
||||
"width": 0.2,
|
||||
"layer": "BL_B_Cu",
|
||||
"net": "N$2",
|
||||
"netCode": 2,
|
||||
},
|
||||
]
|
||||
|
||||
def get_vias(self):
|
||||
return [
|
||||
{
|
||||
"id": "via-1",
|
||||
"position": {"x": 0.5, "y": 0.5},
|
||||
"diameter": 0.8,
|
||||
"drill": 0.4,
|
||||
"net": "N$1",
|
||||
"netCode": 1,
|
||||
},
|
||||
{
|
||||
"id": "via-2",
|
||||
"position": {"x": 20, "y": 20},
|
||||
"diameter": 0.8,
|
||||
"drill": 0.4,
|
||||
"net": "N$2",
|
||||
"netCode": 2,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class _Point:
|
||||
def __init__(self, x, y):
|
||||
self.x = x
|
||||
self.y = y
|
||||
|
||||
|
||||
class _BoxWithPosSize:
|
||||
def __init__(self, x, y, width, height):
|
||||
self.pos = _Point(x, y)
|
||||
self.size = _Point(width, height)
|
||||
|
||||
|
||||
class _BoxWithMinMax:
|
||||
def __init__(self, min_x, min_y, max_x, max_y):
|
||||
self.min = _Point(min_x, min_y)
|
||||
self.max = _Point(max_x, max_y)
|
||||
|
||||
|
||||
class _BoundingBoxBoard:
|
||||
def __init__(self, boxes):
|
||||
self._boxes = boxes
|
||||
|
||||
def get_shapes(self):
|
||||
return list(range(len(self._boxes)))
|
||||
|
||||
def get_item_bounding_box(self, shape):
|
||||
return self._boxes[shape]
|
||||
|
||||
|
||||
def _stub_kipy_units(monkeypatch):
|
||||
units_module = types.ModuleType("kipy.util.units")
|
||||
units_module.to_mm = lambda nm: nm / 1_000_000
|
||||
monkeypatch.setitem(sys.modules, "kipy", types.ModuleType("kipy"))
|
||||
monkeypatch.setitem(sys.modules, "kipy.util", types.ModuleType("kipy.util"))
|
||||
monkeypatch.setitem(sys.modules, "kipy.util.units", units_module)
|
||||
|
||||
|
||||
def test_generic_command_is_tagged_as_swig():
|
||||
iface = _make_iface(
|
||||
{
|
||||
"get_project_info": lambda params: {
|
||||
"success": True,
|
||||
"project": {"name": "demo"},
|
||||
}
|
||||
},
|
||||
use_ipc=True,
|
||||
)
|
||||
|
||||
result = iface.handle_command("get_project_info", {})
|
||||
|
||||
assert result["_backend"] == "swig"
|
||||
assert result["_realtime"] is False
|
||||
|
||||
|
||||
def test_explicit_ipc_command_is_tagged_as_ipc_when_ipc_is_active():
|
||||
iface = _make_iface(
|
||||
{
|
||||
"ipc_add_track": lambda params: {
|
||||
"success": True,
|
||||
"message": "Track added",
|
||||
"realtime": True,
|
||||
}
|
||||
},
|
||||
use_ipc=True,
|
||||
)
|
||||
|
||||
result = iface.handle_command("ipc_add_track", {})
|
||||
|
||||
assert result["_backend"] == "ipc"
|
||||
assert result["_realtime"] is True
|
||||
|
||||
|
||||
def test_backend_info_uses_reported_backend_for_metadata():
|
||||
iface = _make_iface(
|
||||
{
|
||||
"get_backend_info": lambda params: {
|
||||
"success": True,
|
||||
"backend": "ipc",
|
||||
"realtime_sync": True,
|
||||
}
|
||||
},
|
||||
use_ipc=True,
|
||||
)
|
||||
|
||||
result = iface.handle_command("get_backend_info", {})
|
||||
|
||||
assert result["_backend"] == "ipc"
|
||||
assert result["_realtime"] is True
|
||||
|
||||
|
||||
def test_ipc_capable_command_reconnects_when_kicad_is_running(monkeypatch):
|
||||
import kicad_interface
|
||||
|
||||
monkeypatch.setattr(kicad_interface, "KICAD_BACKEND", "auto")
|
||||
monkeypatch.setattr(kicad_interface.KiCADProcessManager, "is_running", lambda: True)
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"kicad_api.ipc_backend",
|
||||
types.SimpleNamespace(IPCBackend=_FakeIPCBackend),
|
||||
)
|
||||
|
||||
iface = _make_iface({}, use_ipc=False)
|
||||
|
||||
result = iface.handle_command("get_board_info", {})
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["_backend"] == "ipc"
|
||||
assert result["_realtime"] is True
|
||||
|
||||
|
||||
def test_ipc_capable_command_does_not_reconnect_in_strict_swig_mode(monkeypatch):
|
||||
import kicad_interface
|
||||
|
||||
monkeypatch.setattr(kicad_interface, "KICAD_BACKEND", "swig")
|
||||
monkeypatch.setattr(kicad_interface.KiCADProcessManager, "is_running", lambda: True)
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"kicad_api.ipc_backend",
|
||||
types.SimpleNamespace(IPCBackend=_ConnectShouldNotBeCalledIPCBackend),
|
||||
)
|
||||
|
||||
iface = _make_iface(
|
||||
{
|
||||
"get_board_info": lambda params: {
|
||||
"success": True,
|
||||
"board": {"filename": "demo.kicad_pcb"},
|
||||
}
|
||||
},
|
||||
use_ipc=False,
|
||||
)
|
||||
|
||||
result = iface.handle_command("get_board_info", {})
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["_backend"] == "swig"
|
||||
assert result["_realtime"] is False
|
||||
assert iface.ipc_board_api is None
|
||||
|
||||
|
||||
def test_ipc_reconnect_failure_falls_back_to_swig(monkeypatch):
|
||||
import kicad_interface
|
||||
|
||||
monkeypatch.setattr(kicad_interface, "KICAD_BACKEND", "auto")
|
||||
monkeypatch.setattr(kicad_interface.KiCADProcessManager, "is_running", lambda: True)
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"kicad_api.ipc_backend",
|
||||
types.SimpleNamespace(IPCBackend=_FailingConnectIPCBackend),
|
||||
)
|
||||
|
||||
iface = _make_iface(
|
||||
{
|
||||
"get_board_info": lambda params: {
|
||||
"success": True,
|
||||
"board": {"filename": "demo.kicad_pcb"},
|
||||
}
|
||||
},
|
||||
use_ipc=False,
|
||||
)
|
||||
|
||||
result = iface.handle_command("get_board_info", {})
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["_backend"] == "swig"
|
||||
assert result["_realtime"] is False
|
||||
assert iface.use_ipc is False
|
||||
assert iface.ipc_board_api is None
|
||||
|
||||
|
||||
def test_connected_ipc_without_board_api_reports_status_but_board_tools_fallback(monkeypatch):
|
||||
import kicad_interface
|
||||
|
||||
monkeypatch.setattr(kicad_interface, "KICAD_BACKEND", "auto")
|
||||
monkeypatch.setattr(kicad_interface.KiCADProcessManager, "is_running", lambda: True)
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"kicad_api.ipc_backend",
|
||||
types.SimpleNamespace(IPCBackend=_NoBoardIPCBackend),
|
||||
)
|
||||
|
||||
iface = _make_iface(
|
||||
{
|
||||
"get_board_info": lambda params: {
|
||||
"success": True,
|
||||
"board": {"filename": "demo.kicad_pcb"},
|
||||
}
|
||||
},
|
||||
use_ipc=False,
|
||||
)
|
||||
iface.command_routes["get_backend_info"] = iface._handle_get_backend_info
|
||||
|
||||
board_result = iface.handle_command("get_board_info", {})
|
||||
backend_result = iface.handle_command("get_backend_info", {})
|
||||
|
||||
assert board_result["success"] is True
|
||||
assert board_result["_backend"] == "swig"
|
||||
assert board_result["_realtime"] is False
|
||||
assert iface.use_ipc is True
|
||||
assert iface.ipc_board_api is None
|
||||
|
||||
assert backend_result["success"] is True
|
||||
assert backend_result["backend"] == "ipc"
|
||||
assert backend_result["realtime_sync"] is True
|
||||
assert backend_result["ipc_connected"] is True
|
||||
assert backend_result["_backend"] == "ipc"
|
||||
|
||||
|
||||
def test_ui_status_tools_report_live_ipc_backend_status(monkeypatch):
|
||||
import kicad_interface
|
||||
|
||||
monkeypatch.setattr(kicad_interface, "KICAD_BACKEND", "auto")
|
||||
monkeypatch.setattr(kicad_interface.KiCADProcessManager, "is_running", lambda self=None: True)
|
||||
monkeypatch.setattr(
|
||||
kicad_interface.KiCADProcessManager,
|
||||
"get_process_info",
|
||||
lambda self: [{"pid": "1234", "name": "pcbnew.exe", "command": "pcbnew.exe"}],
|
||||
)
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"kicad_api.ipc_backend",
|
||||
types.SimpleNamespace(IPCBackend=_FakeIPCBackend),
|
||||
)
|
||||
|
||||
iface = _make_iface({}, use_ipc=False)
|
||||
iface.ipc_backend = _FakeIPCBackend()
|
||||
iface.command_routes = {
|
||||
"check_kicad_ui": iface._handle_check_kicad_ui,
|
||||
"launch_kicad_ui": iface._handle_launch_kicad_ui,
|
||||
"get_backend_info": iface._handle_get_backend_info,
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
kicad_interface,
|
||||
"check_and_launch_kicad",
|
||||
lambda path_obj, auto_launch: {
|
||||
"running": True,
|
||||
"launched": True,
|
||||
"processes": [{"pid": "1234", "name": "pcbnew.exe", "command": "pcbnew.exe"}],
|
||||
"message": "KiCAD launched successfully",
|
||||
"project": str(path_obj) if path_obj else None,
|
||||
},
|
||||
)
|
||||
|
||||
for command in ("check_kicad_ui", "launch_kicad_ui", "get_backend_info"):
|
||||
result = iface.handle_command(command, {})
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["backend"] == "ipc"
|
||||
assert result["realtime_sync"] is True
|
||||
assert result["ipc_connected"] is True
|
||||
assert result["_backend"] == "ipc"
|
||||
assert result["_realtime"] is True
|
||||
|
||||
|
||||
def test_query_traces_can_use_ipc_backend(monkeypatch):
|
||||
import kicad_interface
|
||||
|
||||
monkeypatch.setattr(kicad_interface, "KICAD_BACKEND", "swig")
|
||||
|
||||
iface = _make_iface({}, use_ipc=True)
|
||||
iface.ipc_board_api = _FakeIPCBoardAPI()
|
||||
|
||||
result = iface.handle_command(
|
||||
"query_traces",
|
||||
{"layer": "F.Cu", "boundingBox": {"x1": -1, "y1": -1, "x2": 1, "y2": 1}},
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["traceCount"] == 1
|
||||
assert result["traces"][0]["layer"] == "F.Cu"
|
||||
assert result["traces"][0]["length"] == 5
|
||||
assert result["_backend"] == "ipc"
|
||||
|
||||
|
||||
def test_query_traces_ipc_filters_and_vias(monkeypatch):
|
||||
import kicad_interface
|
||||
|
||||
monkeypatch.setattr(kicad_interface, "KICAD_BACKEND", "swig")
|
||||
|
||||
iface = _make_iface({}, use_ipc=True)
|
||||
iface.ipc_board_api = _FilteringIPCBoardAPI()
|
||||
|
||||
net_miss = iface.handle_command("query_traces", {"net": "NO_MATCH"})
|
||||
layer_match = iface.handle_command("query_traces", {"layer": "B.Cu"})
|
||||
reversed_bbox_with_vias = iface.handle_command(
|
||||
"query_traces",
|
||||
{
|
||||
"net": "N$1",
|
||||
"includeVias": True,
|
||||
"boundingBox": {"x1": 1, "y1": 1, "x2": -1, "y2": -1},
|
||||
},
|
||||
)
|
||||
|
||||
assert net_miss["success"] is True
|
||||
assert net_miss["traceCount"] == 0
|
||||
assert net_miss["_backend"] == "ipc"
|
||||
|
||||
assert layer_match["success"] is True
|
||||
assert layer_match["traceCount"] == 1
|
||||
assert layer_match["traces"][0]["uuid"] == "track-2"
|
||||
assert layer_match["traces"][0]["layer"] == "B.Cu"
|
||||
|
||||
assert reversed_bbox_with_vias["success"] is True
|
||||
assert reversed_bbox_with_vias["traceCount"] == 1
|
||||
assert reversed_bbox_with_vias["traces"][0]["uuid"] == "track-1"
|
||||
assert reversed_bbox_with_vias["viaCount"] == 1
|
||||
assert reversed_bbox_with_vias["vias"][0]["uuid"] == "via-1"
|
||||
|
||||
|
||||
def test_ipc_board_size_supports_kicad_10_box2_pos_size(monkeypatch):
|
||||
from kicad_api.ipc_backend import IPCBoardAPI
|
||||
|
||||
_stub_kipy_units(monkeypatch)
|
||||
board_api = IPCBoardAPI(None, lambda *_args: None)
|
||||
board_api._board = _BoundingBoxBoard(
|
||||
[
|
||||
_BoxWithPosSize(1_000_000, 2_000_000, 3_000_000, 4_000_000),
|
||||
_BoxWithPosSize(0, 1_000_000, 2_000_000, 1_000_000),
|
||||
]
|
||||
)
|
||||
|
||||
result = board_api.get_size()
|
||||
|
||||
assert result == {"width": 4.0, "height": 5.0, "unit": "mm"}
|
||||
|
||||
|
||||
def test_ipc_board_size_keeps_min_max_box2_compatibility(monkeypatch):
|
||||
from kicad_api.ipc_backend import IPCBoardAPI
|
||||
|
||||
_stub_kipy_units(monkeypatch)
|
||||
board_api = IPCBoardAPI(None, lambda *_args: None)
|
||||
board_api._board = _BoundingBoxBoard(
|
||||
[
|
||||
_BoxWithMinMax(1_000_000, 2_000_000, 3_000_000, 4_000_000),
|
||||
_BoxWithMinMax(0, 1_000_000, 2_000_000, 3_000_000),
|
||||
]
|
||||
)
|
||||
|
||||
result = board_api.get_size()
|
||||
|
||||
assert result == {"width": 3.0, "height": 3.0, "unit": "mm"}
|
||||
Reference in New Issue
Block a user