Skip to content

Commit 9bfc6da

Browse files
committed
update pyproject.toml
1 parent 2b425d4 commit 9bfc6da

File tree

3 files changed

+214
-0
lines changed

3 files changed

+214
-0
lines changed

kicad_mcp/tools/datasheet_tools.py

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
"""Datasheet-related MCP tools (uConfig wrapper).
2+
3+
Currently provides one public tool:
4+
5+
extract_symbol_from_pdf(url: str, *, progress_callback) -> {
6+
"symbol_file": "/abs/path/out.kicad_sym",
7+
"pin_table": [...],
8+
}
9+
10+
It downloads the PDF to a temporary directory, invokes **uConfig** to parse the
11+
pin-table and generate a KiCad symbol, then returns the absolute path of the
12+
symbol file along with the extracted pin metadata.
13+
14+
The tool adheres to the canonical MCP *envelope* pattern and re-uses the same
15+
error taxonomy employed by *supplier_tools*.
16+
"""
17+
from __future__ import annotations
18+
19+
import asyncio
20+
import json
21+
import os
22+
import shutil
23+
import tempfile
24+
import time
25+
from pathlib import Path
26+
from typing import Callable, Dict, List, Any
27+
28+
import aiohttp
29+
30+
_ERROR_TYPES = {
31+
"NetworkError",
32+
"ParseError",
33+
"Timeout",
34+
"MissingTool",
35+
}
36+
37+
_ResultEnvelope = Dict[str, object]
38+
39+
_PROGRESS_INTERVAL = 0.5
40+
_DEFAULT_TIMEOUT = aiohttp.ClientTimeout(total=20.0)
41+
42+
UCONFIG_BIN = os.getenv("UCONFIG_BIN", "uconfig")
43+
44+
45+
class DatasheetError(Exception):
46+
def __init__(self, err_type: str, msg: str):
47+
if err_type not in _ERROR_TYPES:
48+
err_type = "ParseError"
49+
self.err_type = err_type
50+
super().__init__(msg)
51+
52+
53+
# ---------------------------------------------------------------------------
54+
# Helpers
55+
# ---------------------------------------------------------------------------
56+
57+
def _envelope_ok(result, start: float) -> _ResultEnvelope:
58+
return {"ok": True, "result": result, "elapsed_s": time.perf_counter() - start}
59+
60+
61+
def _envelope_err(err_type: str, msg: str, start: float) -> _ResultEnvelope:
62+
if err_type not in _ERROR_TYPES:
63+
err_type = "ParseError"
64+
return {
65+
"ok": False,
66+
"error": {"type": err_type, "message": msg},
67+
"elapsed_s": time.perf_counter() - start,
68+
}
69+
70+
71+
async def _periodic_progress(cancel: asyncio.Event, cb: Callable[[float, str], None], msg: str):
72+
pct = 0.0
73+
while not cancel.is_set():
74+
try:
75+
maybe = cb(pct, msg)
76+
if asyncio.iscoroutine(maybe):
77+
await maybe
78+
except Exception:
79+
pass
80+
pct = (pct + 4.0) % 100.0
81+
await asyncio.sleep(_PROGRESS_INTERVAL)
82+
83+
84+
async def _download_pdf(url: str, dest: Path):
85+
try:
86+
async with aiohttp.ClientSession(timeout=_DEFAULT_TIMEOUT) as sess:
87+
async with sess.get(url) as resp:
88+
if resp.status != 200:
89+
raise DatasheetError("NetworkError", f"HTTP {resp.status}")
90+
data = await resp.read()
91+
dest.write_bytes(data)
92+
except asyncio.TimeoutError as te:
93+
raise DatasheetError("Timeout", "download timed out") from te
94+
except DatasheetError:
95+
raise
96+
except Exception as exc:
97+
raise DatasheetError("NetworkError", str(exc)) from exc
98+
99+
100+
async def _run_uconfig(pdf_path: Path, out_dir: Path, timeout: float = 30.0) -> Path:
101+
if shutil.which(UCONFIG_BIN) is None:
102+
raise DatasheetError("MissingTool", "uconfig executable not found; install and/or set UCONFIG_BIN")
103+
104+
proc = await asyncio.create_subprocess_exec(
105+
UCONFIG_BIN,
106+
"--output",
107+
str(out_dir),
108+
str(pdf_path),
109+
stdout=asyncio.subprocess.PIPE,
110+
stderr=asyncio.subprocess.PIPE,
111+
)
112+
try:
113+
await asyncio.wait_for(proc.communicate(), timeout=timeout)
114+
except asyncio.TimeoutError:
115+
proc.kill()
116+
raise DatasheetError("Timeout", "uconfig processing timed out")
117+
118+
if proc.returncode != 0:
119+
stderr = (await proc.stderr.read()).decode(errors="ignore") if proc.stderr else "uconfig failed"
120+
raise DatasheetError("ParseError", stderr.strip())
121+
122+
# uConfig usually writes <part>.kicad_sym in output dir
123+
syms = list(out_dir.glob("*.kicad_sym"))
124+
if not syms:
125+
raise DatasheetError("ParseError", "symbol file not produced by uconfig")
126+
return syms[0]
127+
128+
129+
# ---------------------------------------------------------------------------
130+
# Public MCP tool
131+
# ---------------------------------------------------------------------------
132+
133+
from mcp.server.fastmcp import FastMCP # late import to avoid heavy deps
134+
135+
_mcp_instance: FastMCP | None = None
136+
137+
138+
async def extract_symbol_from_pdf( # noqa: D401
139+
url: str,
140+
*,
141+
progress_callback: Callable[[float, str], None],
142+
) -> _ResultEnvelope:
143+
"""Download *url* PDF and run uConfig, returning symbol path + pin table."""
144+
start = time.perf_counter()
145+
cancel = asyncio.Event()
146+
spinner = asyncio.create_task(_periodic_progress(cancel, progress_callback, "parsing pdf"))
147+
148+
with tempfile.TemporaryDirectory() as tmp:
149+
tmp_dir = Path(tmp)
150+
pdf_file = tmp_dir / "source.pdf"
151+
try:
152+
await _download_pdf(url, pdf_file)
153+
sym_path = await _run_uconfig(pdf_file, tmp_dir)
154+
# uConfig may emit pinout.json; if not available, return empty list
155+
pin_json = next(iter(tmp_dir.glob("*pin*.json")), None)
156+
pin_table: List[Dict[str, Any]] = []
157+
if pin_json and pin_json.exists():
158+
try:
159+
pin_table = json.loads(pin_json.read_text(encoding="utf-8", errors="ignore"))
160+
except Exception:
161+
pin_table = []
162+
cancel.set()
163+
await spinner
164+
return _envelope_ok({"symbol_file": str(sym_path.resolve()), "pin_table": pin_table}, start)
165+
except DatasheetError as de:
166+
cancel.set()
167+
await spinner
168+
return _envelope_err(de.err_type, str(de), start)
169+
except Exception as exc: # pragma: no cover
170+
cancel.set()
171+
await spinner
172+
return _envelope_err("ParseError", str(exc), start)
173+
174+
175+
# ---------------------------------------------------------------------------
176+
# Registration helper
177+
# ---------------------------------------------------------------------------
178+
179+
def register_datasheet_tools(mcp: FastMCP) -> None: # noqa: D401
180+
global _mcp_instance
181+
_mcp_instance = mcp
182+
183+
async def _stub(*args, **kwargs): # type: ignore[override]
184+
return await extract_symbol_from_pdf(*args, **kwargs)
185+
186+
_stub.__name__ = extract_symbol_from_pdf.__name__
187+
_stub.__doc__ = extract_symbol_from_pdf.__doc__
188+
mcp.tool()(_stub)

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ name = "kicad-mcp"
77
version = "0.1.0"
88
authors = [{ name = "Lama Al Rajih" }]
99
description = "Model Context Protocol server for KiCad on Mac, Windows, and Linux"
10+
license = { text = "MIT" }
1011
readme = "README.md"
1112
requires-python = ">=3.10"
1213
classifiers = [
@@ -35,3 +36,6 @@ kicad-mcp = "kicad_mcp.main:main"
3536
where = ["."]
3637
include = ["kicad_mcp*"]
3738
exclude = ["tests*", "docs*"]
39+
40+
[tool.setuptools.package-data]
41+
"kicad_mcp" = ["prompts/*.txt", "resources/**/*.json"]

wip.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
What’s still missing for “make me a breakout board for part X”
2+
3+
Datasheet parsing
4+
• Need a tool that fetches the PDF URL (you already get it) → extracts pin-table text/CSV so the LLM can reason about pin names, functions and spacing.
5+
• Typical libs: pdfminer.six, pymupdf or an external OCR/PDF-to-HTML service.
6+
7+
Symbol / footprint generation
8+
• Either drive KiCad directly via pcbnew/schematic_editor python API, or emit Kicad-6 JSON (.kicad_kicad_sch / .kicad_mod) programmatically.
9+
• Expose those generators as new MCP tools:
10+
• create_symbol(pin_table, ref, footprint)
11+
• create_pcb_footprint(...)
12+
• place_breakout_board(symbol, connector, keepout, …).
13+
14+
Project-level operations
15+
• Tool to start a blank KiCad project in a temp dir, add the new symbol & footprint, wire nets, run ERC/DRC, export Gerbers.
16+
• Example: generate_breakout_project(mpn, connector_type, ...) -> zip.
17+
Front-end prompt(s)
18+
• High-level “Design breakout board” prompt template that sequences the above tools for the LLM.
19+
20+
Optional niceties
21+
• 3-D model lookup (STEP) via Octopart / SnapEDA.
22+
• Board-house DFM rules preset.

0 commit comments

Comments
 (0)