Merge pull request #202 from jbjardine/cojbdev/kicad-mcp-backend-state

Add backend state MCP tool
This commit is contained in:
mixelpixx
2026-05-24 08:51:34 -04:00
committed by GitHub
6 changed files with 304 additions and 1 deletions

View File

@@ -14,7 +14,11 @@ def _make_iface(command_routes, use_ipc=False):
iface.use_ipc = use_ipc
iface.ipc_backend = None
iface.ipc_board_api = None
iface.board = None
iface.command_routes = command_routes
iface._board_disk_signature = None
iface._current_project_path = None
iface._last_auto_save_status = None
return iface
@@ -47,6 +51,20 @@ class _FakeIPCBoardAPI:
return ["F.Cu", "B.Cu"]
class _FakeBoard:
def __init__(self, filename):
self._filename = str(filename)
def GetFileName(self):
return self._filename
def GetDesignSettings(self):
return object()
def GetBoardEdgesBoundingBox(self):
return object()
class _FakeIPCBackend:
def __init__(self):
self.connected = False
@@ -214,6 +232,127 @@ def test_backend_info_uses_reported_backend_for_metadata():
assert result["_realtime"] is True
def test_backend_state_without_board_reports_loaded_flags(monkeypatch):
"""Backend state must explicitly say when no board/project is loaded."""
import kicad_interface
monkeypatch.setattr(kicad_interface.KiCADProcessManager, "is_running", lambda: False)
iface = _make_iface({}, use_ipc=False)
iface.command_routes["get_backend_state"] = iface._handle_get_backend_state
result = iface.handle_command("get_backend_state", {})
assert result["success"] is True
assert result["backend"] == "swig"
assert result["realtime"] is False
assert result["loadedProject"] is False
assert result["loadedBoard"] is False
assert result["projectPath"] is None
assert result["boardPath"] is None
assert result["dirty"] is False
assert result["_backend"] == "swig"
assert result["_realtime"] is False
def test_backend_state_reports_loaded_project_board_and_clean_signature(tmp_path, monkeypatch):
"""A loaded board with a matching disk signature should be visible and clean."""
import kicad_interface
monkeypatch.setattr(kicad_interface.KiCADProcessManager, "is_running", lambda: False)
board_path = tmp_path / "demo.kicad_pcb"
project_path = tmp_path / "demo.kicad_pro"
board_path.write_text("(kicad_pcb demo)\n", encoding="utf-8")
project_path.write_text("{}\n", encoding="utf-8")
iface = _make_iface({}, use_ipc=False)
iface.board = _FakeBoard(board_path)
iface._current_project_path = tmp_path
iface._record_board_signature()
iface.command_routes["get_backend_state"] = iface._handle_get_backend_state
result = iface.handle_command("get_backend_state", {})
assert result["loadedProject"] is True
assert result["loadedBoard"] is True
assert result["projectPath"] == str(project_path.resolve())
assert result["boardPath"] == str(board_path.resolve())
assert result["dirty"] is False
assert result["diskChangedExternally"] is False
def test_backend_state_reports_disk_divergence_as_dirty(tmp_path, monkeypatch):
"""If the board file changed after load, callers need a loud dirty signal."""
import kicad_interface
monkeypatch.setattr(kicad_interface.KiCADProcessManager, "is_running", lambda: False)
board_path = tmp_path / "demo.kicad_pcb"
board_path.write_text("(kicad_pcb original)\n", encoding="utf-8")
iface = _make_iface({}, use_ipc=False)
iface.board = _FakeBoard(board_path)
iface._record_board_signature()
board_path.write_text("(kicad_pcb changed)\n", encoding="utf-8")
iface.command_routes["get_backend_state"] = iface._handle_get_backend_state
result = iface.handle_command("get_backend_state", {})
assert result["loadedBoard"] is True
assert result["dirty"] is True
assert result["diskChangedExternally"] is True
assert "changed on disk" in result["dirtyReason"]
def test_backend_state_reports_unsaved_memory_after_refused_autosave(tmp_path, monkeypatch):
"""Auto-save refusal means the MCP has memory changes that are not persisted."""
import kicad_interface
monkeypatch.setattr(kicad_interface.KiCADProcessManager, "is_running", lambda: False)
board_path = tmp_path / "demo.kicad_pcb"
board_path.write_text("(kicad_pcb demo)\n", encoding="utf-8")
iface = _make_iface({}, use_ipc=False)
iface.board = _FakeBoard(board_path)
iface._record_board_signature()
iface._last_auto_save_status = {
"saved": False,
"memChangesUnsaved": True,
"diskChangedExternally": True,
}
iface.command_routes["get_backend_state"] = iface._handle_get_backend_state
result = iface.handle_command("get_backend_state", {})
assert result["dirty"] is True
assert result["diskChangedExternally"] is True
assert "memory changes" in result["dirtyReason"]
def test_backend_state_reports_ipc_connection_without_loaded_board(monkeypatch):
"""Backend state separates IPC connectivity from whether a board is loaded."""
import kicad_interface
monkeypatch.setattr(kicad_interface.KiCADProcessManager, "is_running", lambda: False)
backend = _FakeIPCBackend()
backend.connected = True
iface = _make_iface({}, use_ipc=True)
iface.ipc_backend = backend
iface.command_routes["get_backend_state"] = iface._handle_get_backend_state
result = iface.handle_command("get_backend_state", {})
assert result["backend"] == "ipc"
assert result["realtime"] is True
assert result["ipcConnected"] is True
assert result["loadedBoard"] is False
assert result["_backend"] == "ipc"
assert result["_realtime"] is True
def test_ipc_capable_command_reconnects_when_kicad_is_running(monkeypatch):
import kicad_interface

View File

@@ -59,3 +59,10 @@ class TestTsToolRegistry:
assert SRC_TOOLS_DIR.is_dir(), f"src/tools/ not found at {SRC_TOOLS_DIR}"
ts_files = list(SRC_TOOLS_DIR.glob("**/*.ts"))
assert ts_files, "No .ts files found in src/tools/"
def test_backend_state_tool_is_registered(self):
"""Backend observability must be exposed as a first-class MCP tool."""
registrations = self._collect_registrations()
tool_names = {name for name, _, _ in registrations}
assert "get_backend_state" in tool_names