260 lines
10 KiB
Python
260 lines
10 KiB
Python
#!/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())
|