#!/usr/bin/env python3 """ Smoke test for the auxiliary KIC-AI MCP servers. This validates the line-delimited JSON-RPC transport and a minimal set of real tool calls against deterministic local fixtures. """ from __future__ import annotations import json import os import shutil import subprocess import tempfile from pathlib import Path from typing import Any, Dict, List, Optional ROOT = Path(__file__).resolve().parent CACHE_ROOT = Path.home() / "Kill_LIFE" / ".cad-home" / "kicad-mcp" / "kicad-v10-libs" SCHEMATIC_FIXTURE = """\ (kicad_sch (version 20250114) (generator "aux-mcp-smoke") (uuid 11111111-1111-1111-1111-111111111111) (paper "A4") (symbol (lib_id "Device:R") (at 10 10 0) (property "Reference" "R1" (at 10 12 0)) (property "Value" "10k" (at 10 8 0)) (property "Footprint" "Resistor_SMD:R_0805_2012Metric" (at 10 6 0)) (property "Datasheet" "~" (at 10 4 0)) ) (symbol (lib_id "Device:C") (at 20 10 0) (property "Reference" "C1" (at 20 12 0)) (property "Value" "100nF" (at 20 8 0)) (property "Footprint" "Capacitor_SMD:C_0805_2012Metric" (at 20 6 0)) (property "Datasheet" "~" (at 20 4 0)) ) (symbol (lib_id "MCU_Microchip_ATmega:ATmega328P-AU") (at 30 10 0) (property "Reference" "U1" (at 30 12 0)) (property "Value" "ATmega328P-AU" (at 30 8 0)) (property "Footprint" "Package_QFP:TQFP-32_7x7mm_P0.8mm" (at 30 6 0)) (property "Manufacturer" "Microchip" (at 30 4 0)) (property "Part" "ATMEGA328P-AU" (at 30 2 0)) ) (symbol (lib_id "Connector_Generic:Conn_01x02") (at 40 10 0) (property "Reference" "J1" (at 40 12 0)) (property "Value" "Conn_01x02" (at 40 8 0)) (property "Footprint" "" (at 40 6 0)) ) (global_label "VCC" (shape input) (at 5 5 0) (fields_autoplaced)) (label "SCL" (at 15 5 0) (fields_autoplaced)) (wire (pts (xy 5 5) (xy 15 5)) (stroke (width 0) (type default)) (uuid abcdefab-cdef-abcd-efab-cdefabcdefab)) (wire (pts (xy 15 5) (xy 25 5)) (stroke (width 0) (type default)) (uuid bcdefabc-defa-bcde-fabc-defabcdefabc)) ) """ PCB_FIXTURE = """\ (kicad_pcb (version 20260206) (generator "pcbnew") (title_block (title "aux-mcp-smoke")) (layers (0 "F.Cu" signal) (2 "B.Cu" signal) (25 "Edge.Cuts" user) ) (gr_rect (start 0 0) (end 50 40) (stroke (width 0.1) (type default)) (fill none) (layer "Edge.Cuts")) (footprint "Resistor_SMD:R_0805_2012Metric" (layer "F.Cu") (tstamp 1)) (footprint "Package_QFP:TQFP-32_7x7mm_P0.8mm" (layer "F.Cu") (tstamp 2)) (segment (start 5 5) (end 10 5) (width 0.25) (layer "F.Cu") (net 1) (tstamp 3)) (zone (net 0) (net_name "") (layer "F.Cu") (tstamp 4) (name "") (hatch edge 0.5) (connect_pads yes (clearance 0.5)) (min_thickness 0.25) (filled_areas_thickness no) (fill yes) (polygon (pts (xy 0 0) (xy 10 0) (xy 10 10) (xy 0 10))) ) ) """ class MCPLineClient: def __init__(self, command: List[str], *, cwd: Optional[Path] = None, env: Optional[Dict[str, str]] = None): process_env = os.environ.copy() if env: process_env.update(env) self.process = subprocess.Popen( command, cwd=cwd or ROOT, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, bufsize=1, env=process_env, ) self._request_id = 1 def close(self) -> None: if self.process.poll() is None: self.process.terminate() try: self.process.wait(timeout=5) except subprocess.TimeoutExpired: self.process.kill() def request(self, method: str, params: Dict[str, Any]) -> Dict[str, Any]: request = {"jsonrpc": "2.0", "id": self._request_id, "method": method, "params": params} self._request_id += 1 assert self.process.stdin is not None assert self.process.stdout is not None self.process.stdin.write(json.dumps(request) + "\n") self.process.stdin.flush() response = self.process.stdout.readline() if not response: stderr = "" if self.process.stderr is not None: stderr = self.process.stderr.read() raise RuntimeError(f"No response for {method}; stderr={stderr.strip()}") payload = json.loads(response) if "error" in payload: raise RuntimeError(f"{method} failed: {payload['error']}") return payload["result"] def assert_true(condition: bool, message: str) -> None: if not condition: raise AssertionError(message) def create_fixture_project() -> Path: tmpdir = Path(tempfile.mkdtemp(prefix="aux-mcp-smoke-")) (tmpdir / "aux_test.kicad_sch").write_text(SCHEMATIC_FIXTURE, encoding="utf-8") (tmpdir / "aux_test.kicad_pcb").write_text(PCB_FIXTURE, encoding="utf-8") return tmpdir def run_component_db_checks(project_dir: Path) -> Dict[str, Any]: client = MCPLineClient( ["python3", "-m", "mcp_servers.component_db"], env={"KICAD_AUX_MCP_ROOTS": str(project_dir)}, ) try: init = client.request("initialize", {}) tools = client.request("tools/list", {}) search = client.request( "tools/call", {"name": "search_components", "arguments": {"type": "microcontroller", "limit": 5}}, ) pricing = client.request( "tools/call", {"name": "get_pricing", "arguments": {"part_numbers": ["Device:R", "Device:C"]}}, ) assert_true(init["serverInfo"]["name"] == "component-database", "component_db initialize failed") assert_true(len(tools["tools"]) == 3, "component_db tools/list mismatch") assert_true(search["total_found"] >= 1, "component_db search returned no results") assert_true("pricing" in pricing, "component_db pricing payload missing") assert_true((CACHE_ROOT / ".symbol_index.sqlite3").exists(), "symbol sqlite index was not created") return {"catalog_size": search["catalog_size"], "search_results": search["total_found"]} finally: client.close() def run_nexar_checks() -> Dict[str, Any]: client = MCPLineClient(["python3", "-m", "mcp_servers.nexar"]) try: init = client.request("initialize", {}) tools = client.request("tools/list", {}) search = client.request( "tools/call", {"name": "search_parts", "arguments": {"query": "STM32", "limit": 2}}, ) assert_true(init["serverInfo"]["name"] == "nexar-api", "nexar initialize failed") assert_true(len(tools["tools"]) >= 4, "nexar tools/list mismatch") assert_true(search["_meta"]["parts"], "nexar search did not expose _meta.parts") return {"parts_found": len(search["_meta"]["parts"]), "demo_mode": search["_meta"]["demo_mode"]} finally: client.close() def run_kicad_tools_checks(project_dir: Path) -> Dict[str, Any]: schematic = project_dir / "aux_test.kicad_sch" pcb = project_dir / "aux_test.kicad_pcb" client = MCPLineClient(["python3", "-m", "mcp_servers.kicad_tools"]) try: init = client.request("initialize", {}) tools = client.request("tools/list", {}) component_list = client.request( "tools/call", {"name": "get_component_list", "arguments": {"schematic_path": str(schematic)}}, ) analysis = client.request( "tools/call", {"name": "analyze_schematic", "arguments": {"schematic_path": str(schematic)}}, ) bom = client.request( "tools/call", {"name": "generate_bom", "arguments": {"schematic_path": str(schematic), "group_by": "value"}}, ) footprints = client.request( "tools/call", {"name": "validate_footprints", "arguments": {"schematic_path": str(schematic)}}, ) pcb_analysis = client.request( "tools/call", {"name": "analyze_pcb", "arguments": {"pcb_path": str(pcb)}}, ) suggestions = client.request( "tools/call", {"name": "suggest_improvements", "arguments": {"project_path": str(schematic)}}, ) assert_true(init["serverInfo"]["name"] == "kicad-tools", "kicad_tools initialize failed") assert_true(len(tools["tools"]) == 6, "kicad_tools tools/list mismatch") assert_true(component_list["component_count"] == 4, "unexpected schematic component count") assert_true(analysis["analysis_summary"]["total_components"] == 4, "schematic analysis mismatch") assert_true(bom["total_components"] == 4, "BOM generation mismatch") assert_true("issues" in footprints, "footprint validation payload missing") assert_true(footprints["local_library_count"] > 0, "container KiCad libraries were not detected") assert_true(footprints["valid_footprints"] >= 3, "expected fixture footprints to resolve against KiCad v10 libraries") assert_true((CACHE_ROOT / ".footprint_index.json").exists(), "footprint index file was not created") assert_true(pcb_analysis["board_info"]["components"] == 2, "pcb analysis mismatch") assert_true("suggestions" in suggestions, "suggest improvements payload missing") return { "components": component_list["component_count"], "bom_items": bom["total_items"], "pcb_components": pcb_analysis["board_info"]["components"], } finally: client.close() def main() -> int: project_dir = create_fixture_project() try: component_db = run_component_db_checks(project_dir) nexar = run_nexar_checks() kicad_tools = run_kicad_tools_checks(project_dir) print( "OK:" f" component_db.catalog={component_db['catalog_size']}" f" component_db.search={component_db['search_results']}" f" nexar.parts={nexar['parts_found']}" f" nexar.demo={nexar['demo_mode']}" f" kicad_tools.components={kicad_tools['components']}" f" kicad_tools.bom_items={kicad_tools['bom_items']}" f" kicad_tools.pcb_components={kicad_tools['pcb_components']}" ) return 0 finally: shutil.rmtree(project_dir, ignore_errors=True) if __name__ == "__main__": raise SystemExit(main())