Add Freerouting autoroute integration

4 new MCP tools: autoroute (full DSN→Freerouting→SES pipeline),
export_dsn, import_ses, check_freerouting. Requires Java 11+ and
freerouting.jar. Includes 21 test cases and README usage examples.
This commit is contained in:
Jeff Laflamme
2026-03-20 11:33:37 +07:00
parent 5b380c0f17
commit 53a8b3ff3e
8 changed files with 941 additions and 0 deletions

View File

@@ -599,6 +599,35 @@ Add a copper pour for GND on the bottom layer covering the entire board.
Create a differential pair for USB_P and USB_N with 0.2mm width and 0.15mm gap.
```
### Autoroute with Freerouting
Automatically route all unconnected nets using the [Freerouting](https://github.com/freerouting/freerouting) autorouter.
**Prerequisites:** Java 11+ and `freerouting.jar` (place at `~/.kicad-mcp/freerouting.jar` or set `FREEROUTING_JAR` env var).
```text
Check if Freerouting is ready on my system.
Autoroute the current board using Freerouting with a 5-minute timeout.
```
**Step-by-step workflow:**
```text
1. Open the project at ~/Projects/LEDBoard/LEDBoard.kicad_pcb
2. Check Freerouting dependencies are installed
3. Run autoroute with max 10 passes
4. Run DRC to verify the autorouted result
5. Export Gerbers to the fabrication folder
```
**Manual DSN/SES workflow** (for advanced users or external autorouters):
```text
Export the board to Specctra DSN format.
# ... run Freerouting GUI or another autorouter externally ...
Import the routed SES file from ~/Projects/LEDBoard/LEDBoard.ses
```
### Design Verification
```text

View File

@@ -0,0 +1,400 @@
"""
Freerouting autoroute integration for KiCAD MCP Server.
Exports the board to Specctra DSN format, runs Freerouting CLI,
and imports the routed SES file back into the board.
"""
import os
import subprocess
import shutil
import time
import logging
from pathlib import Path
from typing import Dict, Any, Optional
logger = logging.getLogger("kicad_interface")
# Default Freerouting JAR location
DEFAULT_FREEROUTING_JAR = os.environ.get(
"FREEROUTING_JAR",
os.path.join(os.path.expanduser("~"), ".kicad-mcp", "freerouting.jar"),
)
def _find_java() -> Optional[str]:
"""Find java executable on the system."""
java = shutil.which("java")
if java:
return java
# Check common locations
for candidate in [
"/usr/bin/java",
"/usr/local/bin/java",
os.path.expandvars("$JAVA_HOME/bin/java"),
]:
if os.path.isfile(candidate):
return candidate
return None
class FreeroutingCommands:
"""Handles Freerouting autoroute operations."""
def __init__(self, board=None):
self.board = board
def autoroute(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Run Freerouting autorouter on the current board.
Flow:
1. Export board to Specctra DSN
2. Run Freerouting CLI on DSN -> SES
3. Import SES back into the board
4. Save the board
"""
try:
import pcbnew
except ImportError:
return {
"success": False,
"message": "pcbnew not available",
"errorDetails": "KiCAD Python API is required",
}
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
board_path = params.get("boardPath")
if not board_path:
board_path = self.board.GetFileName()
if not board_path:
return {
"success": False,
"message": "No board file path available",
"errorDetails": "Provide boardPath or open a project first",
}
jar_path = params.get("freeroutingJar", DEFAULT_FREEROUTING_JAR)
timeout = params.get("timeout", 300)
passes = params.get("maxPasses")
# Validate java
java_exe = _find_java()
if not java_exe:
return {
"success": False,
"message": "Java not found",
"errorDetails": (
"Freerouting requires Java. Install Java 11+ or set "
"JAVA_HOME environment variable."
),
}
# Validate Freerouting JAR
if not os.path.isfile(jar_path):
return {
"success": False,
"message": "Freerouting JAR not found",
"errorDetails": (
f"Expected at: {jar_path}. Download from "
"https://github.com/freerouting/freerouting/releases "
"or set FREEROUTING_JAR env var."
),
}
# Set up file paths
board_dir = os.path.dirname(board_path)
board_stem = Path(board_path).stem
dsn_path = os.path.join(board_dir, f"{board_stem}.dsn")
ses_path = os.path.join(board_dir, f"{board_stem}.ses")
# Step 1: Export DSN
logger.info(f"Exporting DSN to {dsn_path}")
try:
result = pcbnew.ExportSpecctraDSN(self.board, dsn_path)
if result is not True and result != 0:
return {
"success": False,
"message": "DSN export failed",
"errorDetails": f"ExportSpecctraDSN returned: {result}",
}
except Exception as e:
return {
"success": False,
"message": "DSN export failed",
"errorDetails": str(e),
}
if not os.path.isfile(dsn_path):
return {
"success": False,
"message": "DSN file was not created",
"errorDetails": f"Expected at: {dsn_path}",
}
dsn_size = os.path.getsize(dsn_path)
logger.info(f"DSN exported: {dsn_size} bytes")
# Step 2: Run Freerouting
cmd = [
java_exe, "-jar", jar_path,
"-de", dsn_path, "-do", ses_path,
"-mp", str(passes or 20),
]
logger.info(f"Running Freerouting: {' '.join(cmd)}")
start_time = time.time()
try:
proc = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout,
cwd=board_dir,
)
elapsed = round(time.time() - start_time, 1)
if proc.returncode != 0:
return {
"success": False,
"message": f"Freerouting exited with code {proc.returncode}",
"errorDetails": proc.stderr or proc.stdout,
"elapsed_seconds": elapsed,
}
except subprocess.TimeoutExpired:
return {
"success": False,
"message": f"Freerouting timed out after {timeout}s",
"errorDetails": "Increase timeout or reduce board complexity",
}
except Exception as e:
return {
"success": False,
"message": "Failed to run Freerouting",
"errorDetails": str(e),
}
# Check SES output
if not os.path.isfile(ses_path):
return {
"success": False,
"message": "Freerouting did not produce SES output",
"errorDetails": f"Expected at: {ses_path}. Stdout: {proc.stdout[:500]}",
"elapsed_seconds": elapsed,
}
ses_size = os.path.getsize(ses_path)
logger.info(f"SES produced: {ses_size} bytes in {elapsed}s")
# Step 3: Import SES
logger.info(f"Importing SES from {ses_path}")
try:
result = pcbnew.ImportSpecctraSES(self.board, ses_path)
if result is not True and result != 0:
return {
"success": False,
"message": "SES import failed",
"errorDetails": f"ImportSpecctraSES returned: {result}",
"elapsed_seconds": elapsed,
}
except Exception as e:
return {
"success": False,
"message": "SES import failed",
"errorDetails": str(e),
"elapsed_seconds": elapsed,
}
# Step 4: Save board
try:
self.board.Save(board_path)
except Exception as e:
logger.warning(f"Board save after autoroute failed: {e}")
# Collect stats
tracks = self.board.GetTracks()
track_count = 0
via_count = 0
for t in tracks:
if t.GetClass() == "PCB_VIA":
via_count += 1
else:
track_count += 1
return {
"success": True,
"message": f"Autoroute completed in {elapsed}s",
"dsn_path": dsn_path,
"ses_path": ses_path,
"elapsed_seconds": elapsed,
"board_stats": {
"tracks": track_count,
"vias": via_count,
},
"freerouting_stdout": proc.stdout[:1000] if proc.stdout else "",
}
def export_dsn(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Export the board to Specctra DSN format only (no routing)."""
try:
import pcbnew
except ImportError:
return {
"success": False,
"message": "pcbnew not available",
"errorDetails": "KiCAD Python API is required",
}
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
board_path = params.get("boardPath") or self.board.GetFileName()
output_path = params.get("outputPath")
if not output_path:
if board_path:
output_path = os.path.splitext(board_path)[0] + ".dsn"
else:
return {
"success": False,
"message": "No output path",
"errorDetails": "Provide outputPath or have a board file open",
}
try:
result = pcbnew.ExportSpecctraDSN(self.board, output_path)
if result is not True and result != 0:
return {
"success": False,
"message": "DSN export failed",
"errorDetails": f"ExportSpecctraDSN returned: {result}",
}
except Exception as e:
return {
"success": False,
"message": "DSN export failed",
"errorDetails": str(e),
}
file_size = os.path.getsize(output_path) if os.path.isfile(output_path) else 0
return {
"success": True,
"message": f"Exported DSN to {output_path}",
"path": output_path,
"size_bytes": file_size,
}
def import_ses(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Import a Specctra SES file into the board."""
try:
import pcbnew
except ImportError:
return {
"success": False,
"message": "pcbnew not available",
"errorDetails": "KiCAD Python API is required",
}
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
ses_path = params.get("sesPath")
if not ses_path:
return {
"success": False,
"message": "Missing sesPath parameter",
"errorDetails": "Provide the path to the .ses file",
}
if not os.path.isfile(ses_path):
return {
"success": False,
"message": "SES file not found",
"errorDetails": f"File not found: {ses_path}",
}
try:
result = pcbnew.ImportSpecctraSES(self.board, ses_path)
if result is not True and result != 0:
return {
"success": False,
"message": "SES import failed",
"errorDetails": f"ImportSpecctraSES returned: {result}",
}
except Exception as e:
return {
"success": False,
"message": "SES import failed",
"errorDetails": str(e),
}
# Save after import
board_path = params.get("boardPath") or self.board.GetFileName()
if board_path:
try:
self.board.Save(board_path)
except Exception as e:
logger.warning(f"Board save after SES import failed: {e}")
tracks = self.board.GetTracks()
track_count = sum(1 for t in tracks if t.GetClass() != "PCB_VIA")
via_count = sum(1 for t in tracks if t.GetClass() == "PCB_VIA")
return {
"success": True,
"message": f"Imported SES from {ses_path}",
"board_stats": {
"tracks": track_count,
"vias": via_count,
},
}
def check_freerouting(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Check if Freerouting and Java are available."""
jar_path = params.get("freeroutingJar", DEFAULT_FREEROUTING_JAR)
java_exe = _find_java()
java_version = None
if java_exe:
try:
proc = subprocess.run(
[java_exe, "-version"],
capture_output=True,
text=True,
timeout=10,
)
java_version = (proc.stderr or proc.stdout).strip().split("\n")[0]
except Exception:
pass
jar_exists = os.path.isfile(jar_path)
return {
"success": True,
"message": "Freerouting dependency check",
"java": {
"found": java_exe is not None,
"path": java_exe,
"version": java_version,
},
"freerouting": {
"jar_found": jar_exists,
"jar_path": jar_path,
},
"ready": java_exe is not None and jar_exists,
}

View File

@@ -226,6 +226,7 @@ try:
from commands.datasheet_manager import DatasheetManager
from commands.footprint import FootprintCreator
from commands.symbol_creator import SymbolCreator
from commands.freerouting import FreeroutingCommands
logger.info("Successfully imported all command handlers")
except ImportError as e:
@@ -270,6 +271,7 @@ class KiCADInterface:
self.board_commands = BoardCommands(self.board)
self.component_commands = ComponentCommands(self.board, self.footprint_library)
self.routing_commands = RoutingCommands(self.board)
self.freerouting_commands = FreeroutingCommands(self.board)
self.design_rule_commands = DesignRuleCommands(self.board)
self.export_commands = ExportCommands(self.board)
self.library_commands = LibraryCommands(self.footprint_library)
@@ -419,6 +421,11 @@ class KiCADInterface:
"delete_symbol": self._handle_delete_symbol,
"list_symbols_in_library": self._handle_list_symbols_in_library,
"register_symbol_library": self._handle_register_symbol_library,
# Freerouting autoroute commands
"autoroute": self.freerouting_commands.autoroute,
"export_dsn": self.freerouting_commands.export_dsn,
"import_ses": self.freerouting_commands.import_ses,
"check_freerouting": self.freerouting_commands.check_freerouting,
}
logger.info(
@@ -578,6 +585,7 @@ class KiCADInterface:
self.routing_commands.board = self.board
self.design_rule_commands.board = self.board
self.export_commands.board = self.board
self.freerouting_commands.board = self.board
# Schematic command handlers
def _handle_create_schematic(self, params):

View File

@@ -0,0 +1,367 @@
"""
Tests for the Freerouting autoroute integration.
Covers:
- FreeroutingCommands.check_freerouting (dependency detection)
- FreeroutingCommands.export_dsn (DSN export via pcbnew)
- FreeroutingCommands.import_ses (SES import via pcbnew)
- FreeroutingCommands.autoroute (full pipeline)
- Error handling: missing board, missing java, missing JAR, timeouts
"""
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from commands.freerouting import FreeroutingCommands, _find_java
# Ensure the pcbnew mock from conftest is available at module level
# so methods that do `import pcbnew` get the mock.
pcbnew_mock = sys.modules["pcbnew"]
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def reset_pcbnew_mock():
"""Reset pcbnew mock before each test."""
pcbnew_mock.reset_mock()
# Clear side_effects that persist through reset_mock
pcbnew_mock.ExportSpecctraDSN.side_effect = None
pcbnew_mock.ExportSpecctraDSN.return_value = MagicMock()
pcbnew_mock.ImportSpecctraSES.side_effect = None
pcbnew_mock.ImportSpecctraSES.return_value = MagicMock()
yield
@pytest.fixture
def mock_board():
"""Create a mock pcbnew.BOARD with minimal interface."""
board = MagicMock()
board.GetFileName.return_value = "/tmp/test_project/test.kicad_pcb"
board.GetTracks.return_value = []
return board
@pytest.fixture
def cmds(mock_board):
"""FreeroutingCommands with a mock board."""
return FreeroutingCommands(board=mock_board)
@pytest.fixture
def cmds_no_board():
"""FreeroutingCommands without a board."""
return FreeroutingCommands(board=None)
# ---------------------------------------------------------------------------
# check_freerouting
# ---------------------------------------------------------------------------
class TestCheckFreerouting:
def test_java_not_found(self, cmds):
with patch(
"commands.freerouting._find_java", return_value=None
):
result = cmds.check_freerouting(
{"freeroutingJar": "/nonexistent.jar"}
)
assert result["success"] is True
assert result["java"]["found"] is False
assert result["freerouting"]["jar_found"] is False
assert result["ready"] is False
def test_java_found_no_jar(self, cmds):
with patch(
"commands.freerouting._find_java", return_value="/usr/bin/java"
), patch(
"commands.freerouting.subprocess.run"
) as mock_run:
mock_run.return_value = MagicMock(
stderr='openjdk version "17.0.1"', stdout=""
)
result = cmds.check_freerouting(
{"freeroutingJar": "/nonexistent.jar"}
)
assert result["java"]["found"] is True
assert result["java"]["path"] == "/usr/bin/java"
assert result["freerouting"]["jar_found"] is False
assert result["ready"] is False
def test_all_ready(self, cmds, tmp_path):
jar = tmp_path / "freerouting.jar"
jar.touch()
with patch(
"commands.freerouting._find_java", return_value="/usr/bin/java"
), patch(
"commands.freerouting.subprocess.run"
) as mock_run:
mock_run.return_value = MagicMock(
stderr='openjdk version "17.0.1"', stdout=""
)
result = cmds.check_freerouting(
{"freeroutingJar": str(jar)}
)
assert result["ready"] is True
# ---------------------------------------------------------------------------
# export_dsn
# ---------------------------------------------------------------------------
class TestExportDsn:
def test_no_board(self, cmds_no_board):
result = cmds_no_board.export_dsn({})
assert result["success"] is False
assert "No board" in result["message"]
def test_export_success(self, cmds, tmp_path):
board_path = str(tmp_path / "test.kicad_pcb")
dsn_path = str(tmp_path / "test.dsn")
cmds.board.GetFileName.return_value = board_path
pcbnew_mock.ExportSpecctraDSN.return_value = True
# Simulate DSN file creation
Path(dsn_path).write_text("(pcb test)")
result = cmds.export_dsn({})
assert result["success"] is True
assert result["path"] == dsn_path
pcbnew_mock.ExportSpecctraDSN.assert_called_once_with(
cmds.board, dsn_path
)
def test_export_custom_path(self, cmds, tmp_path):
output = str(tmp_path / "custom.dsn")
pcbnew_mock.ExportSpecctraDSN.return_value = True
Path(output).write_text("(pcb test)")
result = cmds.export_dsn({"outputPath": output})
assert result["success"] is True
assert result["path"] == output
def test_export_failure(self, cmds):
pcbnew_mock.ExportSpecctraDSN.side_effect = Exception("DSN error")
result = cmds.export_dsn({})
assert result["success"] is False
assert "DSN error" in result["errorDetails"]
# ---------------------------------------------------------------------------
# import_ses
# ---------------------------------------------------------------------------
class TestImportSes:
def test_no_board(self, cmds_no_board):
result = cmds_no_board.import_ses({"sesPath": "/tmp/test.ses"})
assert result["success"] is False
assert "No board" in result["message"]
def test_missing_ses_path(self, cmds):
result = cmds.import_ses({})
assert result["success"] is False
assert "Missing sesPath" in result["message"]
def test_ses_file_not_found(self, cmds):
result = cmds.import_ses({"sesPath": "/nonexistent/test.ses"})
assert result["success"] is False
assert "not found" in result["message"]
def test_import_success(self, cmds, tmp_path):
ses_file = tmp_path / "test.ses"
ses_file.write_text("(session test)")
pcbnew_mock.ImportSpecctraSES.return_value = True
cmds.board.GetTracks.return_value = []
result = cmds.import_ses({"sesPath": str(ses_file)})
assert result["success"] is True
pcbnew_mock.ImportSpecctraSES.assert_called_once_with(
cmds.board, str(ses_file)
)
def test_import_failure(self, cmds, tmp_path):
ses_file = tmp_path / "test.ses"
ses_file.write_text("(session test)")
pcbnew_mock.ImportSpecctraSES.side_effect = Exception("SES error")
result = cmds.import_ses({"sesPath": str(ses_file)})
assert result["success"] is False
assert "SES error" in result["errorDetails"]
# ---------------------------------------------------------------------------
# autoroute (full pipeline)
# ---------------------------------------------------------------------------
class TestAutoroute:
def test_no_board(self, cmds_no_board):
result = cmds_no_board.autoroute({})
assert result["success"] is False
assert "No board" in result["message"]
def test_no_java(self, cmds):
with patch(
"commands.freerouting._find_java", return_value=None
):
result = cmds.autoroute({})
assert result["success"] is False
assert "Java not found" in result["message"]
def test_no_jar(self, cmds):
with patch(
"commands.freerouting._find_java", return_value="/usr/bin/java"
):
result = cmds.autoroute(
{"freeroutingJar": "/nonexistent/freerouting.jar"}
)
assert result["success"] is False
assert "JAR not found" in result["message"]
@patch("commands.freerouting.subprocess.run")
def test_dsn_export_fails(self, mock_run, cmds, tmp_path):
jar = tmp_path / "freerouting.jar"
jar.touch()
pcbnew_mock.ExportSpecctraDSN.side_effect = Exception(
"export fail"
)
with patch(
"commands.freerouting._find_java", return_value="/usr/bin/java"
):
result = cmds.autoroute({"freeroutingJar": str(jar)})
assert result["success"] is False
assert "DSN export failed" in result["message"]
@patch("commands.freerouting.subprocess.run")
def test_freerouting_timeout(self, mock_run, cmds, tmp_path):
import subprocess
jar = tmp_path / "freerouting.jar"
jar.touch()
board_dir = tmp_path / "project"
board_dir.mkdir()
board_file = board_dir / "test.kicad_pcb"
board_file.touch()
dsn_file = board_dir / "test.dsn"
cmds.board.GetFileName.return_value = str(board_file)
pcbnew_mock.ExportSpecctraDSN.side_effect = (
lambda b, p: (dsn_file.write_text("(pcb)"), True)[1]
)
mock_run.side_effect = subprocess.TimeoutExpired(
cmd="", timeout=10
)
with patch(
"commands.freerouting._find_java", return_value="/usr/bin/java"
):
result = cmds.autoroute(
{"freeroutingJar": str(jar), "timeout": 10}
)
assert result["success"] is False
assert "timed out" in result["message"]
@patch("commands.freerouting.subprocess.run")
def test_full_success(self, mock_run, cmds, tmp_path):
jar = tmp_path / "freerouting.jar"
jar.touch()
board_dir = tmp_path / "project"
board_dir.mkdir()
board_file = board_dir / "test.kicad_pcb"
board_file.touch()
dsn_file = board_dir / "test.dsn"
ses_file = board_dir / "test.ses"
cmds.board.GetFileName.return_value = str(board_file)
# DSN export creates file
pcbnew_mock.ExportSpecctraDSN.side_effect = (
lambda b, p: (dsn_file.write_text("(pcb)"), True)[1]
)
# Freerouting creates SES file
mock_run.return_value = MagicMock(
returncode=0, stdout="Routing completed", stderr=""
)
ses_file.write_text("(session)")
# SES import succeeds
pcbnew_mock.ImportSpecctraSES.return_value = True
# Board tracks after import
track = MagicMock()
track.GetClass.return_value = "PCB_TRACK"
via = MagicMock()
via.GetClass.return_value = "PCB_VIA"
cmds.board.GetTracks.return_value = [track, track, via]
with patch(
"commands.freerouting._find_java", return_value="/usr/bin/java"
):
result = cmds.autoroute({"freeroutingJar": str(jar)})
assert result["success"] is True
assert result["board_stats"]["tracks"] == 2
assert result["board_stats"]["vias"] == 1
assert "elapsed_seconds" in result
pcbnew_mock.ExportSpecctraDSN.assert_called_once()
pcbnew_mock.ImportSpecctraSES.assert_called_once()
@patch("commands.freerouting.subprocess.run")
def test_freerouting_nonzero_exit(
self, mock_run, cmds, tmp_path
):
jar = tmp_path / "freerouting.jar"
jar.touch()
board_dir = tmp_path / "project"
board_dir.mkdir()
board_file = board_dir / "test.kicad_pcb"
board_file.touch()
dsn_file = board_dir / "test.dsn"
cmds.board.GetFileName.return_value = str(board_file)
pcbnew_mock.ExportSpecctraDSN.side_effect = (
lambda b, p: (dsn_file.write_text("(pcb)"), True)[1]
)
mock_run.return_value = MagicMock(
returncode=1, stdout="", stderr="OutOfMemoryError"
)
with patch(
"commands.freerouting._find_java", return_value="/usr/bin/java"
):
result = cmds.autoroute({"freeroutingJar": str(jar)})
assert result["success"] is False
assert "exited with code 1" in result["message"]
# ---------------------------------------------------------------------------
# _find_java helper
# ---------------------------------------------------------------------------
class TestFindJava:
def test_finds_via_which(self):
with patch(
"commands.freerouting.shutil.which",
return_value="/usr/bin/java",
):
assert _find_java() == "/usr/bin/java"
def test_none_when_not_found(self):
with patch(
"commands.freerouting.shutil.which", return_value=None
), patch("os.path.isfile", return_value=False):
assert _find_java() is None

View File

@@ -25,6 +25,7 @@ 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
@@ -251,6 +252,7 @@ export class KiCADMcpServer {
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));

124
src/tools/freerouting.ts Normal file
View File

@@ -0,0 +1,124 @@
/**
* Freerouting autoroute tools for KiCAD MCP server
*
* Provides autorouting via Freerouting (Specctra DSN/SES workflow).
*/
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
export function registerFreeroutingTools(
server: McpServer,
callKicadScript: Function,
) {
// Full autoroute: export DSN -> run Freerouting -> import SES
server.tool(
"autoroute",
"Run Freerouting autorouter on the current PCB. Exports to Specctra DSN, runs Freerouting CLI, and imports the routed SES result. Requires Java 11+ and freerouting.jar (see check_freerouting).",
{
boardPath: z
.string()
.optional()
.describe("Path to .kicad_pcb file (default: current board)"),
freeroutingJar: z
.string()
.optional()
.describe(
"Path to freerouting.jar (default: ~/.kicad-mcp/freerouting.jar or FREEROUTING_JAR env)",
),
maxPasses: z
.number()
.optional()
.describe("Maximum routing passes (default: 20)"),
timeout: z
.number()
.optional()
.describe("Timeout in seconds (default: 300)"),
},
async (args: any) => {
const result = await callKicadScript("autoroute", args);
return {
content: [
{
type: "text",
text: JSON.stringify(result, null, 2),
},
],
};
},
);
// Export DSN only
server.tool(
"export_dsn",
"Export the current PCB to Specctra DSN format. Useful for manual Freerouting workflow or external autorouters.",
{
boardPath: z
.string()
.optional()
.describe("Path to .kicad_pcb file (default: current board)"),
outputPath: z
.string()
.optional()
.describe("Output DSN file path (default: same dir as board)"),
},
async (args: any) => {
const result = await callKicadScript("export_dsn", args);
return {
content: [
{
type: "text",
text: JSON.stringify(result, null, 2),
},
],
};
},
);
// Import SES
server.tool(
"import_ses",
"Import a Specctra SES (session) file into the current PCB. Use after running Freerouting externally.",
{
sesPath: z.string().describe("Path to the .ses file to import"),
boardPath: z
.string()
.optional()
.describe("Path to .kicad_pcb file (default: current board)"),
},
async (args: any) => {
const result = await callKicadScript("import_ses", args);
return {
content: [
{
type: "text",
text: JSON.stringify(result, null, 2),
},
],
};
},
);
// Check Freerouting dependencies
server.tool(
"check_freerouting",
"Check if Java and Freerouting JAR are available on the system. Run this before autoroute to verify prerequisites.",
{
freeroutingJar: z
.string()
.optional()
.describe("Path to freerouting.jar to check"),
},
async (args: any) => {
const result = await callKicadScript("check_freerouting", args);
return {
content: [
{
type: "text",
text: JSON.stringify(result, null, 2),
},
],
};
},
);
}

View File

@@ -16,3 +16,4 @@ export { registerUITools } from "./ui.js";
export { registerDatasheetTools } from "./datasheet.js";
export { registerFootprintTools } from "./footprint.js";
export { registerSymbolCreatorTools } from "./symbol-creator.js";
export { registerFreeroutingTools } from "./freerouting.js";

View File

@@ -126,6 +126,16 @@ export const toolCategories: ToolCategory[] = [
"add_via",
"add_copper_pour"
]
},
{
name: "autoroute",
description: "Freerouting autorouter: automatic PCB routing via Specctra DSN/SES",
tools: [
"autoroute",
"export_dsn",
"import_ses",
"check_freerouting"
]
}
];