feat: extend KiCad AI tooling and plugin integration

This commit is contained in:
Clément SAILLANT
2026-03-08 03:17:21 +01:00
parent 2486f19e0d
commit 764df5dc2e
12 changed files with 2784 additions and 580 deletions
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env python3
"""
Prebuild persistent indexes for the KiCad v10 library cache.
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import sys
SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPT_DIR))
from mcp_servers.kicad_text import ( # type: ignore
clear_container_indexes,
footprint_index_path,
prewarm_container_indexes,
)
from mcp_servers.kicad_symbol_sqlite import build_symbol_sqlite_index, symbol_sqlite_index_path # type: ignore
def main() -> int:
parser = argparse.ArgumentParser(description="Prebuild KiCad v10 symbol and footprint indexes")
parser.add_argument("--refresh", action="store_true", help="Clear existing indexes before rebuilding")
args = parser.parse_args()
if args.refresh:
clear_container_indexes()
symbol_summary = build_symbol_sqlite_index(refresh=False)
summary = prewarm_container_indexes(refresh=False)
summary["symbol_index"] = str(symbol_sqlite_index_path())
summary["symbol_components"] = symbol_summary.get("symbol_components", summary.get("symbol_components", 0))
summary["symbol_libraries"] = symbol_summary.get("symbol_libraries", summary.get("symbol_libraries", 0))
summary["footprint_index"] = str(footprint_index_path())
print(json.dumps(summary, ensure_ascii=True))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+394 -117
View File
@@ -1,40 +1,375 @@
#!/usr/bin/env python3
"""
Simple MCP Server for component database functionality
This demonstrates how to create an MCP server for KiCad integration
KiCad component database MCP server.
The server stays line-delimited JSON-RPC because the existing KIC-AI plugin
clients read one JSON object per line from stdout.
"""
from __future__ import annotations
import asyncio
import json
import sys
import asyncio
from typing import Dict, List, Any
from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence
try:
from .kicad_text import (
KiCadComponent,
container_symbols_dir,
discover_symbol_library_files,
discover_schematic_files,
discover_search_roots,
parse_inline_library_symbols,
parse_schematic_components,
)
from .kicad_symbol_sqlite import (
catalog_size as sqlite_catalog_size,
resolve_symbol_component,
search_symbol_components,
source_count as sqlite_source_count,
source_sample as sqlite_source_sample,
)
except ImportError:
from kicad_text import ( # type: ignore
KiCadComponent,
container_symbols_dir,
discover_symbol_library_files,
discover_schematic_files,
discover_search_roots,
parse_inline_library_symbols,
parse_schematic_components,
)
from kicad_symbol_sqlite import ( # type: ignore
catalog_size as sqlite_catalog_size,
resolve_symbol_component,
search_symbol_components,
source_count as sqlite_source_count,
source_sample as sqlite_source_sample,
)
class ComponentDatabaseServer:
"""Simple component database MCP server"""
"""Component database backed by locally discoverable KiCad files."""
def __init__(self):
self.component_db = {
# Sample component database
"R1": {
"type": "resistor",
"value": "10k",
"package": "0805",
"price": 0.02,
"stock": 5000,
"alternatives": ["R1206-10K", "R0603-10K"]
},
"C1": {
"type": "capacitor",
"value": "100nF",
"package": "0805",
"price": 0.05,
"stock": 2000,
"alternatives": ["C1206-100N", "C0603-100N"]
self.base_dir = Path(__file__).resolve()
self.search_roots = discover_search_roots(self.base_dir)
self.container_symbols_root = container_symbols_dir()
self._catalog: Optional[List[KiCadComponent]] = None
self._sources: Optional[List[str]] = None
def _load_catalog(self) -> List[KiCadComponent]:
if self._catalog is not None:
return self._catalog
components: List[KiCadComponent] = []
source_files: List[str] = []
seen = set()
for schematic_path in discover_schematic_files(self.search_roots):
source_files.append(str(schematic_path))
for component in parse_schematic_components(schematic_path):
key = (
component.lib_id,
component.value,
component.footprint,
component.manufacturer,
component.part_number,
component.lcsc_id,
)
if key in seen:
continue
seen.add(key)
components.append(component)
# Inline libraries from template and expanded schematics are useful
# when no global KiCad libraries are installed on the host.
for component in parse_inline_library_symbols(schematic_path):
key = (
"inline",
component.lib_id,
component.value,
component.footprint,
component.manufacturer,
component.part_number,
component.lcsc_id,
)
if key in seen:
continue
seen.add(key)
components.append(component)
for library_path in discover_symbol_library_files(self.search_roots):
try:
if library_path.resolve().parent == self.container_symbols_root.resolve():
continue
except FileNotFoundError:
pass
source_files.append(str(library_path))
for component in parse_inline_library_symbols(library_path):
key = (
"library",
component.lib_id,
component.value,
component.footprint,
component.manufacturer,
component.part_number,
component.lcsc_id,
)
if key in seen:
continue
seen.add(key)
components.append(component)
self._catalog = components
self._sources = sorted(set(source_files))
return self._catalog
def _local_catalog_sources(self) -> List[str]:
self._load_catalog()
return self._sources or []
def _catalog_sources_sample(self, limit: int = 10) -> List[str]:
sample = list(self._local_catalog_sources()[:limit])
if len(sample) >= limit:
return sample
for source in sqlite_source_sample(limit=limit):
if source in sample:
continue
sample.append(source)
if len(sample) >= limit:
break
return sample
def _catalog_source_count(self) -> int:
return len(self._local_catalog_sources()) + sqlite_source_count()
def _catalog_size(self) -> int:
return len(self._load_catalog()) + sqlite_catalog_size()
def _resolve_component(self, identifier: str) -> Optional[KiCadComponent]:
needle = identifier.strip().lower()
if not needle:
return None
for component in self._load_catalog():
candidates = [
component.lib_id,
component.reference,
component.value,
component.part_number,
component.lcsc_id,
f"{component.lib_id}:{component.reference}",
]
if any(candidate and candidate.lower() == needle for candidate in candidates):
return component
return resolve_symbol_component(identifier)
def _matches_type(self, component: KiCadComponent, component_type: str) -> bool:
if not component_type:
return True
wanted = component_type.strip().lower()
return wanted in component.component_type.lower() or wanted in component.lib_id.lower()
def _matches_specs(self, component: KiCadComponent, specs: Dict[str, Any]) -> bool:
for key, value in specs.items():
expected = str(value).strip().lower()
if not expected:
continue
haystacks: Sequence[str]
key_lower = key.lower()
if key_lower in {"value", "resistance", "capacitance"}:
haystacks = [component.value]
elif key_lower in {"package", "footprint"}:
haystacks = [component.package, component.footprint]
elif key_lower in {"manufacturer", "brand"}:
haystacks = [component.manufacturer]
elif key_lower in {"part", "part_number", "mpn"}:
haystacks = [component.part_number]
elif key_lower in {"lcsc", "lcsc_id"}:
haystacks = [component.lcsc_id]
elif key_lower in {"type", "category"}:
haystacks = [component.component_type, component.category]
else:
haystacks = [
component.reference,
component.value,
component.footprint,
component.description,
component.manufacturer,
component.part_number,
component.lcsc_id,
component.lib_id,
component.category,
]
if not any(expected in (candidate or "").lower() for candidate in haystacks):
return False
return True
def _score_component(self, component: KiCadComponent, component_type: str, specs: Dict[str, Any]) -> int:
score = 0
if component_type:
wanted = component_type.strip().lower()
if component.component_type == wanted:
score += 80
elif wanted in component.lib_id.lower():
score += 40
for key, value in specs.items():
expected = str(value).strip().lower()
if not expected:
continue
if expected == component.value.lower():
score += 60
elif expected in component.value.lower():
score += 30
if expected and expected in component.footprint.lower():
score += 25
if expected and expected in component.part_number.lower():
score += 35
if expected and expected in component.description.lower():
score += 15
if component.lcsc_id:
score += 10
if component.manufacturer:
score += 5
return score
def _alternatives(self, target: KiCadComponent, limit: int = 3) -> List[str]:
matches: List[str] = []
for component in self._load_catalog():
if component.lib_id == target.lib_id:
continue
if component.component_type != target.component_type:
continue
if target.value and component.value and component.value.lower() != target.value.lower():
continue
matches.append(component.lib_id)
if len(matches) >= limit:
return matches
extra = search_symbol_components(
target.component_type,
{"value": target.value} if target.value else {},
limit=limit + 3,
)
for component in extra:
if component.lib_id == target.lib_id or component.lib_id in matches:
continue
matches.append(component.lib_id)
if len(matches) >= limit:
break
return matches
def _search_components(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
component_type = arguments.get("type", "")
specs = arguments.get("specs", {})
limit = int(arguments.get("limit", 20))
matches = [
component
for component in self._load_catalog()
if self._matches_type(component, component_type) and self._matches_specs(component, specs)
]
matches.sort(
key=lambda component: self._score_component(component, component_type, specs),
reverse=True,
)
seen_ids = {component.lib_id for component in matches}
for component in search_symbol_components(component_type, specs, limit=max(limit * 3, limit)):
if component.lib_id in seen_ids:
continue
seen_ids.add(component.lib_id)
matches.append(component)
matches.sort(
key=lambda component: self._score_component(component, component_type, specs),
reverse=True,
)
results = []
for component in matches[:limit]:
payload = component.to_dict(include_values=True, include_footprints=True)
payload["part_id"] = component.lib_id
payload["alternatives"] = self._alternatives(component)
results.append(payload)
return {
"components": results,
"total_found": len(results),
"indexed_source_count": self._catalog_source_count(),
"indexed_sources_sample": self._catalog_sources_sample(),
"catalog_size": self._catalog_size(),
"data_mode": "file-backed-local+container-sqlite",
}
def _get_pricing(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
identifiers = list(arguments.get("part_numbers", []))
if not identifiers and arguments.get("part_number"):
identifiers.append(arguments["part_number"])
pricing: Dict[str, Dict[str, Any]] = {}
for identifier in identifiers:
component = self._resolve_component(identifier)
if component is None:
pricing[identifier] = {
"available": False,
"message": "Component not found in local KiCad catalog",
}
continue
pricing[identifier] = {
"available": component.unit_price is not None,
"unit_price": component.unit_price,
"currency": "USD" if component.unit_price is not None else None,
"stock": component.stock,
"lcsc_id": component.lcsc_id or None,
"manufacturer": component.manufacturer or None,
"part_number": component.part_number or None,
"source": component.source_path,
}
return {
"pricing": pricing,
"data_mode": "file-backed-local+container-sqlite",
"note": "Pricing is only returned when local or cached KiCad symbol metadata contains explicit price fields.",
}
def _check_availability(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
identifiers = list(arguments.get("part_numbers", []))
availability: Dict[str, Any] = {}
details: Dict[str, Dict[str, Any]] = {}
for identifier in identifiers:
component = self._resolve_component(identifier)
if component is None:
availability[identifier] = None
details[identifier] = {
"available": False,
"message": "Component not found in local KiCad catalog",
}
continue
availability[identifier] = component.stock
details[identifier] = {
"available": component.stock is not None,
"stock": component.stock,
"lcsc_id": component.lcsc_id or None,
"source": component.source_path,
}
return {
"availability": availability,
"details": details,
"data_mode": "file-backed-local+container-sqlite",
"note": "Availability is only returned when local or cached KiCad symbol metadata contains explicit stock fields.",
}
async def handle_request(self, request: Dict[str, Any]) -> Dict[str, Any]:
"""Handle MCP requests"""
method = request.get("method")
params = request.get("params", {})
request_id = request.get("id")
@@ -44,18 +379,16 @@ class ComponentDatabaseServer:
"jsonrpc": "2.0",
"id": request_id,
"result": {
"protocolVersion": "2024-11-05",
"capabilities": {
"tools": {}
},
"protocolVersion": "2025-03-26",
"capabilities": {"tools": {}},
"serverInfo": {
"name": "component-database",
"version": "1.0.0"
}
}
"version": "2.1.0",
},
},
}
elif method == "tools/list":
if method == "tools/list":
return {
"jsonrpc": "2.0",
"id": request_id,
@@ -63,123 +396,68 @@ class ComponentDatabaseServer:
"tools": [
{
"name": "search_components",
"description": "Search for components by type and specifications",
"description": "Search components indexed from local KiCad schematics, inline symbol libraries, and cached KiCad v10 symbol libraries",
"inputSchema": {
"type": "object",
"properties": {
"type": {"type": "string"},
"specs": {"type": "object"}
}
}
"specs": {"type": "object"},
"limit": {"type": "integer", "default": 20},
},
},
},
{
"name": "get_pricing",
"description": "Get pricing for component part numbers",
"description": "Return explicit price metadata present in local or cached KiCad symbol properties",
"inputSchema": {
"type": "object",
"properties": {
"part_numbers": {"type": "array", "items": {"type": "string"}}
}
}
"part_numbers": {"type": "array", "items": {"type": "string"}},
"part_number": {"type": "string"},
},
},
},
{
"name": "check_availability",
"description": "Check stock availability for components",
"description": "Return explicit stock metadata present in local or cached KiCad symbol properties",
"inputSchema": {
"type": "object",
"properties": {
"part_numbers": {"type": "array", "items": {"type": "string"}}
}
}
}
"part_numbers": {"type": "array", "items": {"type": "string"}},
},
"required": ["part_numbers"],
},
},
]
}
},
}
elif method == "tools/call":
if method == "tools/call":
tool_name = params.get("name")
arguments = params.get("arguments", {})
if tool_name == "search_components":
component_type = arguments.get("type", "")
specs = arguments.get("specs", {})
# Search through component database
results = []
for part_id, component in self.component_db.items():
# Filter by type if specified
if component_type and component.get("type", "").lower() != component_type.lower():
continue
# Filter by specs if specified
match = True
for spec_key, spec_value in specs.items():
if spec_key in component:
if str(component[spec_key]).lower() != str(spec_value).lower():
match = False
break
if match:
results.append({
"part_id": part_id,
"type": component.get("type"),
"value": component.get("value"),
"package": component.get("package"),
"price": component.get("price"),
"stock": component.get("stock"),
"alternatives": component.get("alternatives", [])
})
return {
"jsonrpc": "2.0",
"id": request_id,
"result": {
"components": results,
"total_found": len(results)
}
}
result = self._search_components(arguments)
elif tool_name == "get_pricing":
part_numbers = arguments.get("part_numbers", [])
pricing = {}
for part in part_numbers:
if part in self.component_db:
pricing[part] = {
"unit_price": self.component_db[part]["price"],
"stock": self.component_db[part]["stock"]
}
return {
"jsonrpc": "2.0",
"id": request_id,
"result": {"pricing": pricing}
}
result = self._get_pricing(arguments)
elif tool_name == "check_availability":
part_numbers = arguments.get("part_numbers", [])
availability = {}
for part in part_numbers:
if part in self.component_db:
availability[part] = self.component_db[part]["stock"]
result = self._check_availability(arguments)
else:
return {
"jsonrpc": "2.0",
"id": request_id,
"result": {"availability": availability}
"error": {"code": -32601, "message": f"Unknown tool: {tool_name}"},
}
return {"jsonrpc": "2.0", "id": request_id, "result": result}
# Default error response
return {
"jsonrpc": "2.0",
"id": request_id,
"error": {"code": -32601, "message": "Method not found"}
"error": {"code": -32601, "message": "Method not found"},
}
async def main():
"""Main MCP server loop"""
server = ComponentDatabaseServer()
# Read from stdin, write to stdout (MCP protocol)
async def main():
server = ComponentDatabaseServer()
while True:
try:
line = sys.stdin.readline()
@@ -188,18 +466,17 @@ async def main():
request = json.loads(line.strip())
response = await server.handle_request(request)
print(json.dumps(response))
sys.stdout.flush()
except Exception as e:
except Exception as exc:
error_response = {
"jsonrpc": "2.0",
"id": None,
"error": {"code": -32603, "message": f"Internal error: {str(e)}"}
"error": {"code": -32603, "message": f"Internal error: {exc}"},
}
print(json.dumps(error_response))
sys.stdout.flush()
if __name__ == "__main__":
asyncio.run(main())
+523
View File
@@ -0,0 +1,523 @@
#!/usr/bin/env python3
"""
SQLite-backed KiCad v10 symbol catalog.
This module indexes the exported KiCad v10 symbol libraries once and exposes
search/query helpers for lightweight MCP servers.
"""
from __future__ import annotations
import json
from pathlib import Path
import sqlite3
import tempfile
from typing import Any, Dict, List, Optional, Sequence, Tuple
try:
from .kicad_text import (
KiCadComponent,
container_library_cache_root,
container_symbols_dir,
ensure_container_library_cache,
parse_inline_library_symbols,
)
except ImportError:
from kicad_text import ( # type: ignore
KiCadComponent,
container_library_cache_root,
container_symbols_dir,
ensure_container_library_cache,
parse_inline_library_symbols,
)
INDEX_VERSION = 1
SYMBOL_SQLITE_FILENAME = ".symbol_index.sqlite3"
def symbol_sqlite_index_path(cache_root: Optional[Path] = None) -> Path:
root = cache_root or container_library_cache_root()
return root / SYMBOL_SQLITE_FILENAME
def _normalize(value: Any) -> str:
return str(value or "").strip().lower()
def _fts_term(value: str) -> str:
escaped = value.replace('"', '""').strip()
return f'"{escaped}"'
def _connect(index_path: Path) -> sqlite3.Connection:
connection = sqlite3.connect(index_path)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA journal_mode=WAL")
connection.execute("PRAGMA synchronous=NORMAL")
connection.execute("PRAGMA temp_store=MEMORY")
return connection
def _index_meta(cache_root: Path) -> Dict[str, Any]:
symbols_dir = container_symbols_dir(cache_root)
library_files = sorted(symbols_dir.glob("*.kicad_sym"))
return {
"version": INDEX_VERSION,
"symbols_dir": str(symbols_dir),
"symbols_dir_mtime_ns": symbols_dir.stat().st_mtime_ns if symbols_dir.exists() else None,
"library_count": len(library_files),
"library_names": [path.name for path in library_files],
}
def _metadata_matches(connection: sqlite3.Connection, current_meta: Dict[str, Any]) -> bool:
row = connection.execute("SELECT value FROM metadata WHERE key = 'meta_json'").fetchone()
if row is None:
return False
try:
cached_meta = json.loads(row["value"])
except json.JSONDecodeError:
return False
return cached_meta == current_meta
def _create_schema(connection: sqlite3.Connection) -> None:
connection.executescript(
"""
CREATE TABLE metadata (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE sources (
path TEXT PRIMARY KEY
);
CREATE TABLE symbols (
id INTEGER PRIMARY KEY AUTOINCREMENT,
lib_id TEXT NOT NULL,
reference TEXT NOT NULL,
value TEXT NOT NULL,
footprint TEXT NOT NULL,
datasheet TEXT NOT NULL,
description TEXT NOT NULL,
manufacturer TEXT NOT NULL,
part_number TEXT NOT NULL,
lcsc_id TEXT NOT NULL,
source_path TEXT NOT NULL,
category TEXT NOT NULL,
component_type TEXT NOT NULL,
package TEXT NOT NULL,
unit_price REAL,
stock INTEGER,
lib_id_lc TEXT NOT NULL,
reference_lc TEXT NOT NULL,
value_lc TEXT NOT NULL,
footprint_lc TEXT NOT NULL,
datasheet_lc TEXT NOT NULL,
description_lc TEXT NOT NULL,
manufacturer_lc TEXT NOT NULL,
part_number_lc TEXT NOT NULL,
lcsc_id_lc TEXT NOT NULL,
source_path_lc TEXT NOT NULL,
category_lc TEXT NOT NULL,
component_type_lc TEXT NOT NULL,
package_lc TEXT NOT NULL
);
CREATE INDEX idx_symbols_component_type ON symbols(component_type_lc);
CREATE INDEX idx_symbols_manufacturer ON symbols(manufacturer_lc);
CREATE INDEX idx_symbols_part_number ON symbols(part_number_lc);
CREATE INDEX idx_symbols_lib_id ON symbols(lib_id_lc);
CREATE INDEX idx_symbols_value ON symbols(value_lc);
CREATE INDEX idx_symbols_footprint ON symbols(footprint_lc);
CREATE INDEX idx_symbols_category ON symbols(category_lc);
CREATE INDEX idx_symbols_reference ON symbols(reference_lc);
CREATE INDEX idx_symbols_lcsc ON symbols(lcsc_id_lc);
CREATE VIRTUAL TABLE symbols_fts USING fts5(
lib_id,
reference,
value,
footprint,
description,
manufacturer,
part_number,
lcsc_id,
category,
component_type,
package
);
"""
)
def _insert_component(connection: sqlite3.Connection, component: KiCadComponent) -> None:
cursor = connection.execute(
"""
INSERT INTO symbols (
lib_id, reference, value, footprint, datasheet, description,
manufacturer, part_number, lcsc_id, source_path, category,
component_type, package, unit_price, stock,
lib_id_lc, reference_lc, value_lc, footprint_lc, datasheet_lc,
description_lc, manufacturer_lc, part_number_lc, lcsc_id_lc,
source_path_lc, category_lc, component_type_lc, package_lc
) VALUES (
?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?,
?, ?, ?, ?,
?, ?, ?, ?, ?,
?, ?, ?, ?,
?, ?, ?, ?
)
""",
(
component.lib_id,
component.reference,
component.value,
component.footprint,
component.datasheet,
component.description,
component.manufacturer,
component.part_number,
component.lcsc_id,
component.source_path,
component.category,
component.component_type,
component.package,
component.unit_price,
component.stock,
_normalize(component.lib_id),
_normalize(component.reference),
_normalize(component.value),
_normalize(component.footprint),
_normalize(component.datasheet),
_normalize(component.description),
_normalize(component.manufacturer),
_normalize(component.part_number),
_normalize(component.lcsc_id),
_normalize(component.source_path),
_normalize(component.category),
_normalize(component.component_type),
_normalize(component.package),
),
)
rowid = cursor.lastrowid
connection.execute(
"""
INSERT INTO symbols_fts (
rowid, lib_id, reference, value, footprint, description,
manufacturer, part_number, lcsc_id, category, component_type, package
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
rowid,
component.lib_id,
component.reference,
component.value,
component.footprint,
component.description,
component.manufacturer,
component.part_number,
component.lcsc_id,
component.category,
component.component_type,
component.package,
),
)
def _row_to_component(row: sqlite3.Row) -> KiCadComponent:
return KiCadComponent(
reference=row["reference"],
value=row["value"],
footprint=row["footprint"],
datasheet=row["datasheet"],
description=row["description"],
manufacturer=row["manufacturer"],
part_number=row["part_number"],
lcsc_id=row["lcsc_id"],
lib_id=row["lib_id"],
source_path=row["source_path"],
category=row["category"],
component_type=row["component_type"],
unit_price=row["unit_price"],
stock=row["stock"],
)
def build_symbol_sqlite_index(
cache_root: Optional[Path] = None,
*,
refresh: bool = False,
) -> Dict[str, Any]:
resolved_root = cache_root or ensure_container_library_cache(refresh=refresh)
if resolved_root is None:
return {
"index_path": None,
"symbol_libraries": 0,
"symbol_components": 0,
"source_count": 0,
}
resolved_root = resolved_root.expanduser()
index_path = symbol_sqlite_index_path(resolved_root)
current_meta = _index_meta(resolved_root)
if index_path.exists() and not refresh:
with _connect(index_path) as connection:
if _metadata_matches(connection, current_meta):
return symbol_sqlite_summary(resolved_root)
fd, tmp_name = tempfile.mkstemp(
prefix=".symbol-index-",
suffix=".sqlite3",
dir=str(resolved_root),
)
Path(tmp_name).unlink(missing_ok=True)
tmp_path = Path(tmp_name)
try:
with _connect(tmp_path) as connection:
_create_schema(connection)
connection.execute(
"INSERT INTO metadata(key, value) VALUES('meta_json', ?)",
(json.dumps(current_meta, ensure_ascii=True),),
)
sources = sorted(container_symbols_dir(resolved_root).glob("*.kicad_sym"))
for source in sources:
connection.execute("INSERT INTO sources(path) VALUES (?)", (str(source),))
for component in parse_inline_library_symbols(source):
_insert_component(connection, component)
connection.commit()
tmp_path.replace(index_path)
return symbol_sqlite_summary(resolved_root)
finally:
tmp_path.unlink(missing_ok=True)
def symbol_sqlite_summary(cache_root: Optional[Path] = None) -> Dict[str, Any]:
resolved_root = cache_root or ensure_container_library_cache()
if resolved_root is None:
return {
"index_path": None,
"symbol_libraries": 0,
"symbol_components": 0,
"source_count": 0,
}
index_path = symbol_sqlite_index_path(resolved_root)
if not index_path.exists():
return build_symbol_sqlite_index(resolved_root, refresh=False)
with _connect(index_path) as connection:
symbol_components = connection.execute("SELECT COUNT(*) AS count FROM symbols").fetchone()["count"]
symbol_libraries = connection.execute("SELECT COUNT(*) AS count FROM sources").fetchone()["count"]
return {
"index_path": str(index_path),
"symbol_libraries": symbol_libraries,
"symbol_components": symbol_components,
"source_count": symbol_libraries,
}
def load_all_symbol_components(
cache_root: Optional[Path] = None,
*,
refresh: bool = False,
) -> Tuple[List[KiCadComponent], List[Path]]:
resolved_root = cache_root or ensure_container_library_cache(refresh=refresh)
if resolved_root is None:
return [], []
build_symbol_sqlite_index(resolved_root, refresh=refresh)
index_path = symbol_sqlite_index_path(resolved_root)
with _connect(index_path) as connection:
rows = connection.execute("SELECT * FROM symbols ORDER BY lib_id").fetchall()
sources = [Path(row["path"]) for row in connection.execute("SELECT path FROM sources ORDER BY path")]
return [_row_to_component(row) for row in rows], sources
def source_sample(cache_root: Optional[Path] = None, limit: int = 10) -> List[str]:
resolved_root = cache_root or ensure_container_library_cache()
if resolved_root is None:
return []
build_symbol_sqlite_index(resolved_root, refresh=False)
with _connect(symbol_sqlite_index_path(resolved_root)) as connection:
rows = connection.execute(
"SELECT path FROM sources ORDER BY path LIMIT ?",
(limit,),
).fetchall()
return [row["path"] for row in rows]
def source_count(cache_root: Optional[Path] = None) -> int:
resolved_root = cache_root or ensure_container_library_cache()
if resolved_root is None:
return 0
build_symbol_sqlite_index(resolved_root, refresh=False)
with _connect(symbol_sqlite_index_path(resolved_root)) as connection:
return connection.execute("SELECT COUNT(*) AS count FROM sources").fetchone()["count"]
def catalog_size(cache_root: Optional[Path] = None) -> int:
resolved_root = cache_root or ensure_container_library_cache()
if resolved_root is None:
return 0
build_symbol_sqlite_index(resolved_root, refresh=False)
with _connect(symbol_sqlite_index_path(resolved_root)) as connection:
return connection.execute("SELECT COUNT(*) AS count FROM symbols").fetchone()["count"]
def resolve_symbol_component(identifier: str, cache_root: Optional[Path] = None) -> Optional[KiCadComponent]:
needle = _normalize(identifier)
if not needle:
return None
resolved_root = cache_root or ensure_container_library_cache()
if resolved_root is None:
return None
build_symbol_sqlite_index(resolved_root, refresh=False)
query = """
SELECT *
FROM symbols
WHERE lib_id_lc = ?
OR reference_lc = ?
OR value_lc = ?
OR part_number_lc = ?
OR lcsc_id_lc = ?
OR (lib_id_lc || ':' || reference_lc) = ?
LIMIT 1
"""
with _connect(symbol_sqlite_index_path(resolved_root)) as connection:
row = connection.execute(query, (needle, needle, needle, needle, needle, needle)).fetchone()
return _row_to_component(row) if row is not None else None
def _apply_spec_filter(
where_clauses: List[str],
params: List[Any],
score_terms: List[str],
score_params: List[Any],
key: str,
value: str,
) -> None:
normalized = _normalize(value)
if not normalized:
return
if key in {"value", "resistance", "capacitance"}:
where_clauses.append("instr(value_lc, ?) > 0")
params.append(normalized)
score_terms.append("CASE WHEN value_lc = ? THEN 60 WHEN instr(value_lc, ?) > 0 THEN 30 ELSE 0 END")
score_params.extend([normalized, normalized])
return
if key in {"package"}:
where_clauses.append("instr(package_lc, ?) > 0")
params.append(normalized)
score_terms.append("CASE WHEN package_lc = ? THEN 35 WHEN instr(package_lc, ?) > 0 THEN 20 ELSE 0 END")
score_params.extend([normalized, normalized])
return
if key in {"footprint"}:
where_clauses.append("(instr(package_lc, ?) > 0 OR instr(footprint_lc, ?) > 0)")
params.extend([normalized, normalized])
score_terms.append(
"CASE WHEN footprint_lc = ? THEN 35 WHEN instr(footprint_lc, ?) > 0 THEN 25 "
"WHEN package_lc = ? THEN 25 WHEN instr(package_lc, ?) > 0 THEN 15 ELSE 0 END"
)
score_params.extend([normalized, normalized, normalized, normalized])
return
if key in {"manufacturer", "brand"}:
where_clauses.append("instr(manufacturer_lc, ?) > 0")
params.append(normalized)
score_terms.append(
"CASE WHEN manufacturer_lc = ? THEN 55 WHEN instr(manufacturer_lc, ?) > 0 THEN 25 ELSE 0 END"
)
score_params.extend([normalized, normalized])
return
if key in {"part", "part_number", "mpn"}:
where_clauses.append("instr(part_number_lc, ?) > 0")
params.append(normalized)
score_terms.append(
"CASE WHEN part_number_lc = ? THEN 90 WHEN instr(part_number_lc, ?) > 0 THEN 45 ELSE 0 END"
)
score_params.extend([normalized, normalized])
return
if key in {"lcsc", "lcsc_id"}:
where_clauses.append("instr(lcsc_id_lc, ?) > 0")
params.append(normalized)
score_terms.append(
"CASE WHEN lcsc_id_lc = ? THEN 90 WHEN instr(lcsc_id_lc, ?) > 0 THEN 45 ELSE 0 END"
)
score_params.extend([normalized, normalized])
return
if key in {"type", "category"}:
where_clauses.append("(instr(component_type_lc, ?) > 0 OR instr(category_lc, ?) > 0)")
params.extend([normalized, normalized])
score_terms.append(
"CASE WHEN component_type_lc = ? THEN 70 WHEN instr(component_type_lc, ?) > 0 THEN 35 "
"WHEN instr(category_lc, ?) > 0 THEN 20 ELSE 0 END"
)
score_params.extend([normalized, normalized, normalized])
return
where_clauses.append(
"id IN (SELECT rowid FROM symbols_fts WHERE symbols_fts MATCH ?)"
)
params.append(_fts_term(normalized))
score_terms.append("20")
def search_symbol_components(
component_type: str,
specs: Dict[str, Any],
*,
limit: int = 20,
cache_root: Optional[Path] = None,
) -> List[KiCadComponent]:
resolved_root = cache_root or ensure_container_library_cache()
if resolved_root is None:
return []
build_symbol_sqlite_index(resolved_root, refresh=False)
where_clauses = ["1 = 1"]
params: List[Any] = []
score_terms: List[str] = []
score_params: List[Any] = []
wanted_type = _normalize(component_type)
if wanted_type:
where_clauses.append("(component_type_lc = ? OR instr(lib_id_lc, ?) > 0)")
params.extend([wanted_type, wanted_type])
score_terms.append(
"CASE WHEN component_type_lc = ? THEN 80 WHEN instr(lib_id_lc, ?) > 0 THEN 40 ELSE 0 END"
)
score_params.extend([wanted_type, wanted_type])
for key, value in specs.items():
_apply_spec_filter(where_clauses, params, score_terms, score_params, key.lower(), str(value))
score_sql = " + ".join(score_terms) if score_terms else "0"
sql = f"""
SELECT *,
({score_sql}) AS score
FROM symbols
WHERE {' AND '.join(where_clauses)}
ORDER BY score DESC, lib_id ASC
LIMIT ?
"""
with _connect(symbol_sqlite_index_path(resolved_root)) as connection:
rows = connection.execute(sql, tuple(score_params + params + [max(limit * 10, limit)])).fetchall()
return [_row_to_component(row) for row in rows[:limit]]
+871
View File
@@ -0,0 +1,871 @@
#!/usr/bin/env python3
"""
Shared KiCad text parsing helpers for lightweight MCP servers.
These helpers intentionally work without pcbnew or KiCad system libraries.
They read `.kicad_sch` and `.kicad_pcb` files directly and only report facts
that can be derived from the available local files.
"""
from __future__ import annotations
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Dict, Iterable, List, Optional, Sequence, Tuple
import math
import json
import os
import re
import shutil
import subprocess
DEFAULT_KICAD_V10_IMAGE = "kill_life_cad-kicad-mcp:latest"
_CONTAINER_CACHE_STATE: Optional[Path] = None
_CONTAINER_CACHE_FAILED = False
_CONTAINER_FOOTPRINT_INDEX_CACHE: Optional[Tuple[str, int, Dict[str, Path]]] = None
_FOOTPRINT_NAME_CACHE: Dict[str, Tuple[str, ...]] = {}
INDEX_VERSION = 1
SYMBOL_INDEX_FILENAME = ".symbol_index.sqlite3"
FOOTPRINT_INDEX_FILENAME = ".footprint_index.json"
PROPERTY_PATTERN = re.compile(r'\(property\s+"([^"]+)"\s+"([^"]*)"')
LIB_ID_PATTERN = re.compile(r'\(lib_id\s+"([^"]+)"')
QUOTED_SYMBOL_PATTERN = re.compile(r'^\(symbol\s+"([^"]+)"')
LABEL_PATTERN = re.compile(r'\((?:global_label|label|hierarchical_label)\s+"([^"]+)"')
TITLE_PATTERN = re.compile(r'\(title\s+"([^"]*)"')
LAYER_PATTERN = re.compile(r'\(\s*\d+\s+"([^"]+)"\s+([^)]+)\)')
EDGE_LINE_PATTERN = re.compile(
r'\(gr_line\b.*?\(start\s+([-0-9.]+)\s+([-0-9.]+)\)\s*'
r'\(end\s+([-0-9.]+)\s+([-0-9.]+)\).*?'
r'\(layer\s+"Edge\.Cuts"\)',
re.DOTALL,
)
EDGE_RECT_PATTERN = re.compile(
r'\(gr_rect\b.*?\(start\s+([-0-9.]+)\s+([-0-9.]+)\)\s*'
r'\(end\s+([-0-9.]+)\s+([-0-9.]+)\).*?'
r'\(layer\s+"Edge\.Cuts"\)',
re.DOTALL,
)
EDGE_CIRCLE_PATTERN = re.compile(
r'\(gr_circle\b.*?\(center\s+([-0-9.]+)\s+([-0-9.]+)\)\s*'
r'\(end\s+([-0-9.]+)\s+([-0-9.]+)\).*?'
r'\(layer\s+"Edge\.Cuts"\)',
re.DOTALL,
)
EDGE_ARC_PATTERN = re.compile(
r'\(gr_arc\b.*?\(start\s+([-0-9.]+)\s+([-0-9.]+)\)\s*'
r'\(mid\s+([-0-9.]+)\s+([-0-9.]+)\)\s*'
r'\(end\s+([-0-9.]+)\s+([-0-9.]+)\).*?'
r'\(layer\s+"Edge\.Cuts"\)',
re.DOTALL,
)
def read_text(path: Path) -> str:
return path.read_text(encoding="utf-8", errors="ignore")
def env_is_enabled(name: str, default: bool = True) -> bool:
value = os.getenv(name)
if value is None:
return default
return value.strip().lower() not in {"0", "false", "no", "off", ""}
def container_library_cache_root() -> Path:
override = os.getenv("KICAD_AUX_CONTAINER_CACHE_DIR")
if override:
return Path(override).expanduser()
return Path.home() / "Kill_LIFE" / ".cad-home" / "kicad-mcp" / "kicad-v10-libs"
def container_symbols_dir(cache_root: Optional[Path] = None) -> Path:
root = cache_root or container_library_cache_root()
return root / "symbols"
def container_footprints_dir(cache_root: Optional[Path] = None) -> Path:
root = cache_root or container_library_cache_root()
return root / "footprints"
def symbol_index_path(cache_root: Optional[Path] = None) -> Path:
root = cache_root or container_library_cache_root()
return root / SYMBOL_INDEX_FILENAME
def footprint_index_path(cache_root: Optional[Path] = None) -> Path:
root = cache_root or container_library_cache_root()
return root / FOOTPRINT_INDEX_FILENAME
def ensure_container_library_cache(refresh: bool = False) -> Optional[Path]:
global _CONTAINER_CACHE_STATE, _CONTAINER_CACHE_FAILED
if _CONTAINER_CACHE_STATE is not None and not refresh:
return _CONTAINER_CACHE_STATE
if _CONTAINER_CACHE_FAILED and not refresh:
return None
if not env_is_enabled("KICAD_AUX_USE_CONTAINER_LIBS", True):
_CONTAINER_CACHE_FAILED = True
return None
if shutil.which("docker") is None:
_CONTAINER_CACHE_FAILED = True
return None
image = os.getenv("KICAD_MCP_IMAGE", DEFAULT_KICAD_V10_IMAGE)
cache_root = container_library_cache_root()
symbols_dir = cache_root / "symbols"
footprints_dir = cache_root / "footprints"
if not refresh and symbols_dir.is_dir() and footprints_dir.is_dir():
_CONTAINER_CACHE_STATE = cache_root
return cache_root
if subprocess.run(
["docker", "image", "inspect", image],
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
).returncode != 0:
_CONTAINER_CACHE_FAILED = True
return None
container_id = ""
tmp_root = cache_root.with_name(f".{cache_root.name}.tmp")
try:
if tmp_root.exists():
shutil.rmtree(tmp_root)
tmp_root.parent.mkdir(parents=True, exist_ok=True)
tmp_root.mkdir(parents=True, exist_ok=True)
container_id = subprocess.check_output(["docker", "create", image], text=True).strip()
subprocess.run(
["docker", "cp", f"{container_id}:/usr/share/kicad/symbols", str(tmp_root)],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
subprocess.run(
["docker", "cp", f"{container_id}:/usr/share/kicad/footprints", str(tmp_root)],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
if cache_root.exists():
shutil.rmtree(cache_root)
tmp_root.rename(cache_root)
_CONTAINER_CACHE_STATE = cache_root
_CONTAINER_CACHE_FAILED = False
return cache_root
except (subprocess.CalledProcessError, OSError):
_CONTAINER_CACHE_FAILED = True
return None
finally:
if container_id:
subprocess.run(
["docker", "rm", "-f", container_id],
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
if tmp_root.exists():
shutil.rmtree(tmp_root, ignore_errors=True)
def _safe_relpath(path: Path, root: Path) -> str:
try:
return str(path.resolve().relative_to(root.resolve()))
except ValueError:
return str(path)
def _footprint_index_metadata(cache_root: Path) -> Dict[str, object]:
footprints_dir = container_footprints_dir(cache_root)
dirs = sorted(footprints_dir.glob("*.pretty"))
return {
"version": INDEX_VERSION,
"footprints_dir": str(footprints_dir),
"footprints_dir_mtime_ns": footprints_dir.stat().st_mtime_ns if footprints_dir.exists() else None,
"library_count": len(dirs),
"library_names": [path.name for path in dirs],
}
def _metadata_matches(current: Dict[str, object], cached: Dict[str, object]) -> bool:
for key, value in current.items():
if cached.get(key) != value:
return False
return True
def load_container_symbol_index(
cache_root: Optional[Path] = None,
*,
refresh: bool = False,
) -> Tuple[List["KiCadComponent"], List[Path]]:
try:
from .kicad_symbol_sqlite import load_all_symbol_components
except ImportError:
from kicad_symbol_sqlite import load_all_symbol_components # type: ignore
return load_all_symbol_components(cache_root, refresh=refresh)
def load_container_footprint_index(
cache_root: Optional[Path] = None,
*,
refresh: bool = False,
) -> Dict[str, Path]:
global _CONTAINER_FOOTPRINT_INDEX_CACHE
resolved_root = cache_root or ensure_container_library_cache(refresh=refresh)
if resolved_root is None:
return {}
resolved_root = resolved_root.expanduser()
current_meta = _footprint_index_metadata(resolved_root)
cache_signature = int(current_meta.get("footprints_dir_mtime_ns") or 0)
cache_key = str(resolved_root)
if (
_CONTAINER_FOOTPRINT_INDEX_CACHE is not None
and _CONTAINER_FOOTPRINT_INDEX_CACHE[0] == cache_key
and _CONTAINER_FOOTPRINT_INDEX_CACHE[1] == cache_signature
and not refresh
):
return _CONTAINER_FOOTPRINT_INDEX_CACHE[2]
index_file = footprint_index_path(resolved_root)
if index_file.exists() and not refresh:
try:
payload = json.loads(index_file.read_text(encoding="utf-8"))
cached_meta = payload.get("meta", {})
if isinstance(cached_meta, dict) and _metadata_matches(current_meta, cached_meta):
libraries: Dict[str, Path] = {}
_FOOTPRINT_NAME_CACHE.clear()
for library_name, entry in payload.get("libraries", {}).items():
relative_path = entry.get("relative_path")
if not relative_path:
continue
full_path = resolved_root / relative_path
libraries[library_name] = full_path
_FOOTPRINT_NAME_CACHE[str(full_path)] = tuple(entry.get("footprints", []))
_CONTAINER_FOOTPRINT_INDEX_CACHE = (cache_key, cache_signature, libraries)
return libraries
except (OSError, json.JSONDecodeError, TypeError, ValueError):
pass
libraries: Dict[str, Path] = {}
serialized: Dict[str, Dict[str, object]] = {}
_FOOTPRINT_NAME_CACHE.clear()
footprints_dir = container_footprints_dir(resolved_root)
for library_path in sorted(footprints_dir.glob("*.pretty")):
if not library_path.is_dir():
continue
library_name = library_path.stem
if library_name in libraries:
continue
footprint_names = tuple(sorted(path.stem for path in library_path.glob("*.kicad_mod")))
libraries[library_name] = library_path
_FOOTPRINT_NAME_CACHE[str(library_path)] = footprint_names
serialized[library_name] = {
"relative_path": _safe_relpath(library_path, resolved_root),
"footprints": list(footprint_names),
}
payload = {"meta": current_meta, "libraries": serialized}
index_file.write_text(json.dumps(payload, ensure_ascii=True), encoding="utf-8")
_CONTAINER_FOOTPRINT_INDEX_CACHE = (cache_key, cache_signature, libraries)
return libraries
def prewarm_container_indexes(refresh: bool = False) -> Dict[str, object]:
cache_root = ensure_container_library_cache(refresh=refresh)
if cache_root is None:
return {
"cache_root": None,
"symbol_libraries": 0,
"symbol_components": 0,
"footprint_libraries": 0,
}
try:
from .kicad_symbol_sqlite import build_symbol_sqlite_index
except ImportError:
from kicad_symbol_sqlite import build_symbol_sqlite_index # type: ignore
symbol_summary = build_symbol_sqlite_index(cache_root, refresh=refresh)
footprint_libraries = load_container_footprint_index(cache_root, refresh=refresh)
return {
"cache_root": str(cache_root),
"symbol_libraries": symbol_summary.get("symbol_libraries", 0),
"symbol_components": symbol_summary.get("symbol_components", 0),
"footprint_libraries": len(footprint_libraries),
}
def clear_container_indexes(cache_root: Optional[Path] = None) -> None:
global _CONTAINER_FOOTPRINT_INDEX_CACHE
resolved_root = cache_root or container_library_cache_root()
legacy_symbol_index = resolved_root / ".symbol_index.json"
for index_file in (symbol_index_path(resolved_root), footprint_index_path(resolved_root)):
try:
index_file.unlink()
except FileNotFoundError:
pass
try:
legacy_symbol_index.unlink()
except FileNotFoundError:
pass
_CONTAINER_FOOTPRINT_INDEX_CACHE = None
def extract_blocks(text: str, keyword: str) -> List[str]:
"""Return every S-expression block whose head is `keyword`."""
target = f"({keyword}"
blocks: List[str] = []
start = 0
while True:
idx = text.find(target, start)
if idx == -1:
return blocks
depth = 0
in_string = False
escaped = False
for pos in range(idx, len(text)):
char = text[pos]
if in_string:
if escaped:
escaped = False
elif char == "\\":
escaped = True
elif char == '"':
in_string = False
continue
if char == '"':
in_string = True
continue
if char == "(":
depth += 1
elif char == ")":
depth -= 1
if depth == 0:
blocks.append(text[idx : pos + 1])
start = pos + 1
break
else:
return blocks
def parse_properties(block: str) -> Dict[str, str]:
return {match.group(1): match.group(2) for match in PROPERTY_PATTERN.finditer(block)}
def parse_float(value: Optional[str]) -> Optional[float]:
if value in (None, ""):
return None
cleaned = str(value).strip().replace(",", ".")
match = re.search(r"-?\d+(?:\.\d+)?", cleaned)
if not match:
return None
try:
return float(match.group(0))
except ValueError:
return None
def parse_int(value: Optional[str]) -> Optional[int]:
if value in (None, ""):
return None
cleaned = str(value).strip().replace(",", "")
match = re.search(r"-?\d+", cleaned)
if not match:
return None
try:
return int(match.group(0))
except ValueError:
return None
def derive_package(footprint: str) -> str:
if not footprint:
return ""
footprint_name = footprint.split(":", 1)[-1]
match = re.search(
r"(0201|0402|0603|0805|1206|1210|SOT-23(?:-[0-9]+)?|SOT-223|"
r"SOIC-[0-9]+|TSSOP-[0-9]+|QFN-[0-9]+|TQFP-[0-9]+|LQFP-[0-9]+|"
r"DIP-[0-9]+|PinHeader_[^:_]+|JST_[^:_]+)",
footprint_name,
re.IGNORECASE,
)
return match.group(1) if match else footprint_name
def infer_component_type(
reference: str = "",
lib_id: str = "",
value: str = "",
description: str = "",
category: str = "",
) -> str:
reference = (reference or "").upper()
lib_id_lower = (lib_id or "").lower()
haystack = " ".join([lib_id_lower, value.lower(), description.lower(), category.lower()])
if "regulator" in haystack or "ldo" in haystack:
return "regulator"
if "microcontroller" in haystack or "mcu" in haystack:
return "microcontroller"
if "opamp" in haystack or "amplifier" in haystack:
return "opamp"
if "connector" in haystack or lib_id_lower.startswith("connector"):
return "connector"
if "switch" in haystack:
return "switch"
if "mosfet" in haystack:
return "mosfet"
if "transistor" in haystack or lib_id_lower.startswith("device:q_"):
return "transistor"
if "crystal" in haystack or "oscillator" in haystack:
return "crystal"
if "inductor" in haystack or lib_id_lower.startswith("device:l"):
return "inductor"
if "capacitor" in haystack or lib_id_lower.startswith("device:c"):
return "capacitor"
if "resistor" in haystack or lib_id_lower.startswith("device:r"):
return "resistor"
if "led" in haystack:
return "led"
if "diode" in haystack or lib_id_lower.startswith("device:d"):
return "diode"
if "power" in haystack or lib_id_lower.startswith("power:"):
return "power"
if reference.startswith("R"):
return "resistor"
if reference.startswith("C"):
return "capacitor"
if reference.startswith("L"):
return "inductor"
if reference.startswith("D"):
return "diode"
if reference.startswith("Q"):
return "transistor"
if reference.startswith("U"):
return "ic"
if reference.startswith("J"):
return "connector"
if reference.startswith("Y"):
return "crystal"
if reference.startswith("SW"):
return "switch"
return "other"
def derive_manufacturer(lib_id: str, manufacturer: str) -> str:
if manufacturer:
return manufacturer
library_name = lib_id.split(":", 1)[0]
parts = [part for part in library_name.split("_") if part]
if len(parts) >= 2 and parts[0] in {"MCU", "CPU", "DSP", "FPGA", "CPLD"}:
aliases = {
"ST": "STMicroelectronics",
"NXP": "NXP",
"TI": "Texas Instruments",
}
return aliases.get(parts[1], parts[1])
return manufacturer
def derive_category(lib_id: str, category: str) -> str:
if category:
return category
return lib_id.split(":", 1)[0].replace("_", " ")
@dataclass
class KiCadComponent:
reference: str
value: str
footprint: str
datasheet: str
description: str
manufacturer: str
part_number: str
lcsc_id: str
lib_id: str
source_path: str
category: str
component_type: str
unit_price: Optional[float] = None
stock: Optional[int] = None
@property
def package(self) -> str:
return derive_package(self.footprint)
def to_dict(
self,
include_values: bool = True,
include_footprints: bool = True,
) -> Dict[str, object]:
result = asdict(self)
result["type"] = result.pop("component_type")
result["package"] = self.package if include_footprints else ""
result["value"] = self.value if include_values else None
result["footprint"] = self.footprint if include_footprints else None
result["price"] = self.unit_price
return result
def _build_component(
*,
reference: str,
value: str,
footprint: str,
datasheet: str,
description: str,
manufacturer: str,
part_number: str,
lcsc_id: str,
lib_id: str,
source_path: Path,
category: str,
price_raw: str,
stock_raw: str,
) -> KiCadComponent:
component_type = infer_component_type(
reference=reference,
lib_id=lib_id,
value=value,
description=description,
category=category,
)
return KiCadComponent(
reference=reference,
value=value,
footprint=footprint,
datasheet=datasheet,
description=description,
manufacturer=derive_manufacturer(lib_id, manufacturer),
part_number=part_number,
lcsc_id=lcsc_id,
lib_id=lib_id,
source_path=str(source_path),
category=derive_category(lib_id, category),
component_type=component_type,
unit_price=parse_float(price_raw),
stock=parse_int(stock_raw),
)
def parse_inline_library_symbols(path: Path) -> List[KiCadComponent]:
text = read_text(path)
components: List[KiCadComponent] = []
for block in extract_blocks(text, "symbol"):
match = QUOTED_SYMBOL_PATTERN.match(block)
if not match:
continue
symbol_name = match.group(1)
if re.search(r"_\d+_\d+$", symbol_name):
continue
if path.suffix == ".kicad_sym" and ":" not in symbol_name:
symbol_ref = f"{path.stem}:{symbol_name}"
else:
symbol_ref = symbol_name
properties = parse_properties(block)
components.append(
_build_component(
reference=properties.get("Reference", symbol_ref.split(":")[-1]),
value=properties.get("Value", symbol_ref.split(":")[-1]),
footprint=properties.get("Footprint", ""),
datasheet=properties.get("Datasheet", ""),
description=properties.get("Description", ""),
manufacturer=properties.get("Manufacturer", ""),
part_number=properties.get(
"Part",
properties.get("MPN", symbol_ref.split(":")[-1]),
),
lcsc_id=properties.get("LCSC", properties.get("LCSC Part", "")),
lib_id=symbol_ref,
source_path=path,
category=properties.get("Category", ""),
price_raw=properties.get("Price", ""),
stock_raw=properties.get("Stock", ""),
)
)
return components
def parse_schematic_components(
path: Path,
*,
include_templates: bool = False,
) -> List[KiCadComponent]:
text = read_text(path)
components: List[KiCadComponent] = []
for block in extract_blocks(text, "symbol"):
lib_id_match = LIB_ID_PATTERN.search(block)
if not lib_id_match:
continue
properties = parse_properties(block)
reference = properties.get("Reference", "")
if not reference:
continue
if not include_templates and reference.startswith("_TEMPLATE"):
continue
components.append(
_build_component(
reference=reference,
value=properties.get("Value", lib_id_match.group(1).split(":")[-1]),
footprint=properties.get("Footprint", ""),
datasheet=properties.get("Datasheet", ""),
description=properties.get("Description", ""),
manufacturer=properties.get("Manufacturer", ""),
part_number=properties.get(
"Part",
properties.get("MPN", lib_id_match.group(1).split(":")[-1]),
),
lcsc_id=properties.get("LCSC", properties.get("LCSC Part", "")),
lib_id=lib_id_match.group(1),
source_path=path,
category=properties.get("Category", ""),
price_raw=properties.get("Price", ""),
stock_raw=properties.get("Stock", ""),
)
)
return sorted(components, key=lambda item: item.reference)
def parse_schematic_metrics(path: Path) -> Dict[str, object]:
text = read_text(path)
labels = sorted(set(LABEL_PATTERN.findall(text)))
return {
"wire_count": text.count("(wire "),
"junction_count": text.count("(junction "),
"label_count": len(labels),
"labels": labels,
"sheet_count": text.count("(sheet "),
}
def _extract_layer_block(text: str) -> str:
blocks = extract_blocks(text, "layers")
return blocks[0] if blocks else ""
def _edge_cut_points(text: str) -> List[Tuple[float, float]]:
points: List[Tuple[float, float]] = []
for match in EDGE_LINE_PATTERN.finditer(text):
x1, y1, x2, y2 = map(float, match.groups())
points.extend([(x1, y1), (x2, y2)])
for match in EDGE_RECT_PATTERN.finditer(text):
x1, y1, x2, y2 = map(float, match.groups())
points.extend([(x1, y1), (x2, y2)])
for match in EDGE_CIRCLE_PATTERN.finditer(text):
cx, cy, ex, ey = map(float, match.groups())
radius = math.dist((cx, cy), (ex, ey))
points.extend(
[
(cx - radius, cy - radius),
(cx + radius, cy + radius),
]
)
for match in EDGE_ARC_PATTERN.finditer(text):
coords = list(map(float, match.groups()))
points.extend([(coords[0], coords[1]), (coords[2], coords[3]), (coords[4], coords[5])])
return points
def parse_pcb_summary(path: Path) -> Dict[str, object]:
text = read_text(path)
layer_block = _extract_layer_block(text)
copper_layers: List[str] = []
for name, layer_type in LAYER_PATTERN.findall(layer_block):
if name.endswith(".Cu") or "signal" in layer_type:
copper_layers.append(name)
points = _edge_cut_points(text)
width_mm = None
height_mm = None
if points:
xs = [point[0] for point in points]
ys = [point[1] for point in points]
width_mm = round(max(xs) - min(xs), 3)
height_mm = round(max(ys) - min(ys), 3)
title_match = TITLE_PATTERN.search(text)
return {
"title": title_match.group(1) if title_match else path.stem,
"copper_layers": copper_layers,
"footprint_count": len(re.findall(r"\(footprint\b", text)),
"segment_count": len(re.findall(r"\(segment\b", text)),
"via_count": len(re.findall(r"\(via\b", text)),
"zone_count": len(re.findall(r"\(zone\b", text)),
"net_count": len(set(re.findall(r'\(net\s+\d+\s+"([^"]*)"', text))),
"edge_cuts_count": text.count('Edge.Cuts'),
"width_mm": width_mm,
"height_mm": height_mm,
}
def discover_search_roots(base_dir: Path, env_var: str = "KICAD_AUX_MCP_ROOTS") -> List[Path]:
extra_roots: List[Path] = []
env_value = os.getenv(env_var, "")
if env_value:
extra_roots.extend(Path(entry).expanduser() for entry in env_value.split(os.pathsep) if entry)
sibling_templates = base_dir.parents[1] / "kicad_mcp_server" / "python" / "templates"
container_cache_root = ensure_container_library_cache(
refresh=env_is_enabled("KICAD_AUX_CONTAINER_REFRESH", False)
)
default_roots = [
Path.cwd(),
base_dir.parents[1],
sibling_templates,
Path.home() / "Documents" / "KiCad",
Path.home() / ".local" / "share" / "kicad",
Path.home() / "Kill_LIFE" / ".cad-home" / "kicad-mcp",
container_cache_root,
]
roots: List[Path] = []
seen = set()
for root in [*extra_roots, *default_roots]:
if root is None:
continue
root = root.expanduser()
key = str(root.resolve()) if root.exists() else str(root)
if key in seen or not root.exists():
continue
seen.add(key)
roots.append(root)
return roots
def discover_schematic_files(roots: Sequence[Path], limit: int = 64) -> List[Path]:
files: List[Path] = []
seen = set()
for root in roots:
for path in root.rglob("*.kicad_sch"):
key = str(path.resolve())
if key in seen:
continue
seen.add(key)
files.append(path)
if len(files) >= limit:
return files
return files
def discover_symbol_library_files(roots: Sequence[Path], limit: int = 512) -> List[Path]:
files: List[Path] = []
seen = set()
for root in roots:
for path in root.rglob("*.kicad_sym"):
key = str(path.resolve())
if key in seen:
continue
seen.add(key)
files.append(path)
if len(files) >= limit:
return files
return files
def discover_footprint_libraries(roots: Sequence[Path], limit: int = 256) -> Dict[str, Path]:
libraries: Dict[str, Path] = {}
container_root = ensure_container_library_cache(
refresh=env_is_enabled("KICAD_AUX_CONTAINER_REFRESH", False)
)
container_libraries_loaded = False
for root in roots:
if container_root is not None:
try:
root_resolved = root.resolve()
container_resolved = container_root.resolve()
if root_resolved == container_resolved or container_resolved in root_resolved.parents:
if not container_libraries_loaded:
for name, path in load_container_footprint_index(container_root).items():
libraries.setdefault(name, path)
if len(libraries) >= limit:
return libraries
container_libraries_loaded = True
continue
except FileNotFoundError:
pass
for path in root.rglob("*.pretty"):
if not path.is_dir():
continue
libraries.setdefault(path.stem, path)
if len(libraries) >= limit:
return libraries
return libraries
def footprint_exists(footprint_spec: str, libraries: Dict[str, Path]) -> bool:
if not footprint_spec:
return False
if ":" in footprint_spec:
library_name, footprint_name = footprint_spec.split(":", 1)
library_path = libraries.get(library_name)
if not library_path:
return False
cached_names = _FOOTPRINT_NAME_CACHE.get(str(library_path))
if cached_names:
return footprint_name in cached_names
return (library_path / f"{footprint_name}.kicad_mod").exists()
footprint_name = footprint_spec
for path in libraries.values():
cached_names = _FOOTPRINT_NAME_CACHE.get(str(path))
if cached_names:
if footprint_name in cached_names:
return True
continue
if (path / f"{footprint_name}.kicad_mod").exists():
return True
return False
def search_footprints(
libraries: Dict[str, Path],
patterns: Iterable[str],
limit: int = 5,
) -> List[str]:
results: List[str] = []
seen = set()
lowered_patterns = [pattern.lower() for pattern in patterns if pattern]
for library_name, library_path in libraries.items():
cached_names = _FOOTPRINT_NAME_CACHE.get(str(library_path))
if cached_names:
footprint_names = list(cached_names)
else:
footprint_names = [candidate.stem for candidate in library_path.glob("*.kicad_mod")]
for footprint_name in footprint_names:
search_text = f"{library_name}:{footprint_name}".lower()
if lowered_patterns and not any(pattern in search_text for pattern in lowered_patterns):
continue
full_name = f"{library_name}:{footprint_name}"
if full_name in seen:
continue
seen.add(full_name)
results.append(full_name)
if len(results) >= limit:
return results
return results
+562 -387
View File
File diff suppressed because it is too large Load Diff
+100 -45
View File
@@ -9,6 +9,7 @@ import json
import sys
import asyncio
import os
import logging
from typing import Dict, List, Any, Optional
try:
@@ -17,17 +18,55 @@ try:
except ImportError:
REQUESTS_AVAILABLE = False
LOG_LEVEL = os.getenv("NEXAR_MCP_LOG_LEVEL", "WARNING").upper()
logging.basicConfig(level=getattr(logging, LOG_LEVEL, logging.WARNING))
logger = logging.getLogger("kicad_kic_ai.nexar_mcp")
class NexarServer:
"""MCP server for Nexar API integration (formerly Octopart)"""
def __init__(self):
self.api_token = os.getenv('NEXAR_TOKEN')
self.api_token = os.getenv('NEXAR_TOKEN') or os.getenv('NEXAR_API_KEY')
self.base_url = "https://api.nexar.com/graphql"
# Demo mode with realistic data
self.demo_mode = not self.api_token
if self.demo_mode:
print("Info: No NEXAR_TOKEN found, using demo pricing mode", file=sys.stderr)
logger.info("No Nexar token configured, using demo mode")
def _result_payload(self, summary_text: str, **meta: Any) -> Dict[str, Any]:
return {
"content": [{"type": "text", "text": summary_text}],
"isError": False,
"_meta": meta,
**meta,
}
def _part_with_client_aliases(self, part: Dict[str, Any]) -> Dict[str, Any]:
normalized = dict(part)
mpn = normalized.get("mpn") or normalized.get("part_number")
if mpn:
normalized["mpn"] = mpn
normalized["part_number"] = mpn
distributors = normalized.get("distributors", {})
pricing = {}
for distributor, details in distributors.items():
if "price_1" in details:
pricing[distributor] = details
continue
unit_price = details.get("unit_price")
pricing[distributor] = {
"price_1": unit_price,
"stock": details.get("stock"),
"minimum_order": details.get("minimum_order"),
"url": details.get("url"),
}
if pricing:
normalized["pricing"] = pricing
return normalized
async def handle_request(self, request: Dict[str, Any]) -> Dict[str, Any]:
"""Handle MCP requests"""
@@ -40,7 +79,7 @@ class NexarServer:
"jsonrpc": "2.0",
"id": request_id,
"result": {
"protocolVersion": "2024-11-05",
"protocolVersion": "2025-03-26",
"capabilities": {
"tools": {}
},
@@ -174,10 +213,7 @@ class NexarServer:
try:
return await self._search_parts_api(args)
except Exception as e:
print(f"Nexar API call failed, using demo mode: {e}", file=sys.stderr)
# Demo mode - comprehensive pricing examples
print("Warning: No NEXAR_TOKEN configured, using demo data", file=sys.stderr)
logger.warning("Nexar API call failed, falling back to demo mode: %s", e)
# Create demo responses based on search query
demo_parts = []
@@ -193,7 +229,7 @@ class NexarServer:
value = "2.2K"
demo_parts = [
{
self._part_with_client_aliases({
"mpn": f"RC0805FR-07{value}L",
"manufacturer": "Yageo",
"description": f"RES SMD {value} OHM 1% 1/8W 0805",
@@ -241,12 +277,12 @@ class NexarServer:
"package": "0805",
"temperature_coefficient": "±100ppm/°C"
}
}
})
]
elif 'capacitor' in query.lower() or 'cap' in query.lower():
demo_parts = [
{
self._part_with_client_aliases({
"mpn": "CL21B104KBCNNNC",
"manufacturer": "Samsung Electro-Mechanics",
"description": "CAP CER 100NF 50V X7R 0805",
@@ -284,13 +320,13 @@ class NexarServer:
"dielectric": "X7R",
"package": "0805"
}
}
})
]
else:
# Generic search result
demo_parts = [
{
self._part_with_client_aliases({
"mpn": "DEMO-PART-001",
"manufacturer": "Demo Manufacturer",
"description": f"Demo component for: {query}",
@@ -305,16 +341,25 @@ class NexarServer:
"stock": 8000,
"minimum_order": 1
}
}
})
]
return {
"parts": demo_parts,
"total_count": len(demo_parts),
"demo_mode": True,
"message": "Demo data - add NEXAR_TOKEN for live pricing from distributors",
"distributors_covered": ["Digi-Key", "Mouser", "Farnell", "Newark", "Arrow", "RS Components", "Avnet"]
}
return self._result_payload(
f"Found {len(demo_parts)} parts in demo mode",
parts=demo_parts,
total_count=len(demo_parts),
demo_mode=True,
message="Demo data - add NEXAR_TOKEN for live distributor pricing",
distributors_covered=[
"Digi-Key",
"Mouser",
"Farnell",
"Newark",
"Arrow",
"RS Components",
"Avnet",
],
)
except Exception as e:
return {"error": f"Search failed: {str(e)}"}
@@ -385,15 +430,18 @@ class NexarServer:
"specifications": {spec.get('attribute', {}).get('name', ''): spec.get('value', '')
for spec in part.get('specs', [])}
}
parts.append(part_info)
parts.append(self._part_with_client_aliases(part_info))
return {
"parts": parts,
"total_count": len(parts),
"demo_mode": False,
"message": f"Found {len(parts)} parts via Nexar API",
"distributors_covered": list(set([d for p in parts for d in p.get('distributors', {}).keys()]))
}
return self._result_payload(
f"Found {len(parts)} parts via Nexar API",
parts=parts,
total_count=len(parts),
demo_mode=False,
message=f"Found {len(parts)} parts via Nexar API",
distributors_covered=list(
set([d for p in parts for d in p.get('distributors', {}).keys()])
),
)
else:
raise Exception(f"API error: {response.status_code} - {response.text}")
@@ -458,7 +506,11 @@ class NexarServer:
"demo_mode": True
}
return demo_pricing
return self._result_payload(
f"Pricing for {mpn} ({'demo' if self.demo_mode else 'api'} mode)",
pricing=demo_pricing,
demo_mode=self.demo_mode,
)
except Exception as e:
return {"error": f"Failed to get pricing: {str(e)}"}
@@ -503,16 +555,18 @@ class NexarServer:
"lead_time": "In Stock"
})
return {
"parts": best_prices,
"total_bom_cost": round(total_cost, 2),
"average_unit_price": round(total_cost / sum(p.get('quantity', 1) for p in parts), 4),
"recommended_distributor": "Mouser",
"estimated_shipping": 15.00,
"grand_total": round(total_cost + 15.00, 2),
"demo_mode": True,
"message": "Demo BOM pricing - real API provides accurate multi-distributor comparison"
}
total_quantity = sum(p.get('quantity', 1) for p in parts) or 1
return self._result_payload(
f"Calculated best pricing for {len(parts)} parts",
parts=best_prices,
total_bom_cost=round(total_cost, 2),
average_unit_price=round(total_cost / total_quantity, 4),
recommended_distributor="Mouser",
estimated_shipping=15.00,
grand_total=round(total_cost + 15.00, 2),
demo_mode=True,
message="Demo BOM pricing - real API provides accurate multi-distributor comparison",
)
except Exception as e:
return {"error": f"Failed to calculate best prices: {str(e)}"}
@@ -545,12 +599,13 @@ class NexarServer:
}
]
return {
"original_part": mpn,
"alternatives": alternatives,
"total_alternatives": len(alternatives),
"demo_mode": True
}
return self._result_payload(
f"Found {len(alternatives)} alternatives for {mpn}",
original_part=mpn,
alternatives=alternatives,
total_alternatives=len(alternatives),
demo_mode=True,
)
except Exception as e:
return {"error": f"Failed to find alternatives: {str(e)}"}
+1 -1
View File
@@ -204,7 +204,7 @@ class MCPServerConfigPanel(wx.Panel):
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"protocolVersion": "2025-03-26",
"capabilities": {"tools": {}},
"clientInfo": {"name": "config-test", "version": "1.0.0"}
}
+1 -1
View File
@@ -41,7 +41,7 @@ class MCPClient:
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"protocolVersion": "2025-03-26",
"capabilities": {
"tools": {}
},
+4 -4
View File
@@ -2,8 +2,8 @@
"mcp_servers": {
"component_database": {
"command": ["python3", "-m", "mcp_servers.component_db"],
"description": "Component database with pricing and availability",
"enabled": false
"description": "Component database indexed from local KiCad schematics and inline symbol libraries",
"enabled": true
},
"nexar_api": {
"command": ["python3", "-m", "mcp_servers.nexar"],
@@ -13,8 +13,8 @@
},
"kicad_tools": {
"command": ["python3", "-m", "mcp_servers.kicad_tools"],
"description": "KiCad design manipulation tools",
"enabled": false
"description": "KiCad design analysis and BOM tools backed by local KiCad files",
"enabled": true
}
},
"ai_enhancement": {
+1 -1
View File
@@ -58,7 +58,7 @@ class NexarServer:
def handle_initialize(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Handle MCP initialize request"""
return {
"protocolVersion": "2024-11-05",
"protocolVersion": "2025-03-26",
"capabilities": self.capabilities,
"serverInfo": {
"name": "nexar-server",
+2 -3
View File
@@ -38,7 +38,7 @@ class EmbeddedNexarServer:
def handle_initialize(self, params):
mode_info = "Demo mode" if self.demo_mode else f"API mode (key: {self.api_key[:8]}...)"
return {
"protocolVersion": "2024-11-05",
"protocolVersion": "2025-03-26",
"capabilities": self.capabilities,
"serverInfo": {
"name": "embedded-nexar-server",
@@ -156,11 +156,10 @@ class SimpleMCPClient:
try:
# Try to find external server files first
possible_paths = [
os.path.join(os.path.dirname(__file__), 'nexar_server.py'),
os.path.join(os.path.dirname(os.path.dirname(__file__)), 'mcp_servers', 'nexar.py'),
os.path.join(os.path.dirname(__file__), '..', 'mcp_servers', 'nexar.py'),
os.path.join('mcp_servers', 'nexar.py'),
os.path.join(os.path.dirname(os.path.dirname(__file__)), 'mcp_servers', 'nexar.py')
os.path.join(os.path.dirname(__file__), 'nexar_server.py'),
]
server_path = None
+259
View File
@@ -0,0 +1,259 @@
#!/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())