Files
Kill_LIFE/tools/apify_mcp.py
T
kxkm 1c6c1952e8 fix+feat: restore CI/PIO regressions, 30-entry HF dataset, new tests & hardware
Regressions fixed (aa916de simplification):
- firmware/platformio.ini: restore esp32s3_waveshare (pioarduino platform,
  lib_deps: ArduinoJson/ESP32_Display_Panel/ESP32-audioI2S/IO_Expander,
  BOARD_HAS_PSRAM, I2S pins), restore esp32s3_qemu (extends waveshare +
  QEMU_BUILD), fix default_envs=esp32s3_waveshare, keep build_dir=/tmp/kl_pio_build
- .github/workflows/ci.yml: restore firmware-native (112 Unity tests),
  firmware-build (esp32s3_waveshare artifact), firmware-sim (Wokwi gated),
  hardware-export (KiCad ERC + SVG/PDF/netlist + KiBot + compliance)
- .gitignore: add .kibot-venv/ and .pio-venv/ (prevent committing venvs)

Dataset (HuggingFace clemsail/kill-life-embedded-qa v2):
- generate_hf_dataset.py: rag_query timeout 30s->120s (LLM takes ~45s),
  rag_search timeout 15s->30s; resolves intermittent server-busy failures
- 30 entries (10+10+5+5) -- 100% coverage vs 21/40 previously

Firmware tests (112/112):
- 4 suites: test_basic (39), test_modules (32), test_radio_state (26),
  test_wifi_state (15); test_logic.cpp now a stub (content moved to test_basic)

Hardware:
- esp32_minimal.kicad_pcb, esp32s3_enclosure.FCStd/.step, gen_pcb.py,
  gen_enclosure.py, REGISTRY.md, ERC reports for all design blocks

MCP tools:
- apify_mcp.py: +5 Kill_LIFE tools (fetch_espressif_docs, fetch_kicad_library_info,
  fetch_platformio_registry, ingest_to_rag, get_runtime_info)
- mcp_runtime_status.py: fix classify_overall -- accept_degraded respected for
  failed checks, task annotations, optional_degraded logic cleaned up
- deploy/cad/docker-compose.yml: path mascarade->mascarade-main

Specs & docs:
- docs/plans/TODO_2026-03-26.md, TODO_2026-03-27.md
- ai-agentic-embedded-base/specs: arch, tasks, intake, spec updates
- docs/playbooks/kicad_happy_hw_bom_forge.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 22:14:33 +01:00

494 lines
20 KiB
Python

#!/usr/bin/env python3
"""Local MCP server for Apify web scraping — datasheets, components, forums, docs."""
from __future__ import annotations
import asyncio
import hashlib
import json
import os
import re
import sys
from pathlib import Path
from typing import Any
from mcp_stdio import ( # type: ignore
PROTOCOL_VERSION,
error_tool_result,
make_error,
make_response,
ok_tool_result,
read_message,
write_message,
)
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
APIFY_TOKEN = os.getenv("APIFY_API_TOKEN", "")
APIFY_BASE = "https://api.apify.com/v2"
USER_AGENT = "Kill_LIFE-Apify-MCP/1.0"
# ---------------------------------------------------------------------------
# HTTP helpers (httpx with fallback to urllib)
# ---------------------------------------------------------------------------
try:
import httpx
async def _fetch(url: str, *, method: str = "GET", json_body: dict | None = None,
headers: dict | None = None, timeout: float = 60.0) -> dict | str:
h = {"User-Agent": USER_AGENT, **(headers or {})}
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as c:
if method == "POST":
r = await c.post(url, json=json_body, headers=h)
else:
r = await c.get(url, headers=h)
r.raise_for_status()
try:
return r.json()
except Exception:
return r.text
async def _fetch_text(url: str, *, timeout: float = 30.0) -> str:
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as c:
r = await c.get(url, headers={"User-Agent": USER_AGENT})
r.raise_for_status()
return r.text
except ImportError:
import urllib.request
async def _fetch(url: str, **_kw: Any) -> dict | str:
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
with urllib.request.urlopen(req, timeout=60) as resp:
data = resp.read().decode()
try:
return json.loads(data)
except Exception:
return data
async def _fetch_text(url: str, **_kw: Any) -> str:
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
with urllib.request.urlopen(req, timeout=30) as resp:
return resp.read().decode()
# ---------------------------------------------------------------------------
# Apify helpers
# ---------------------------------------------------------------------------
async def _run_actor(actor_id: str, input_data: dict, timeout: float = 120.0) -> dict:
"""Run an Apify Actor and wait for results."""
if not APIFY_TOKEN:
raise RuntimeError("APIFY_API_TOKEN not set — set it in .env or environment")
url = f"{APIFY_BASE}/acts/{actor_id}/runs?token={APIFY_TOKEN}&waitForFinish={int(timeout)}"
result = await _fetch(url, method="POST", json_body=input_data, timeout=timeout + 10)
if isinstance(result, dict) and result.get("data", {}).get("status") == "SUCCEEDED":
dataset_id = result["data"].get("defaultDatasetId")
if dataset_id:
items = await _fetch(f"{APIFY_BASE}/datasets/{dataset_id}/items?token={APIFY_TOKEN}")
return {"status": "ok", "items": items if isinstance(items, list) else [items]}
return {"status": result.get("data", {}).get("status", "UNKNOWN"), "raw": result}
async def _direct_scrape(url: str) -> str:
"""Fallback: scrape a URL directly via httpx."""
text = await _fetch_text(url, timeout=30)
# Strip HTML tags for a rough text extraction
clean = re.sub(r"<script[^>]*>.*?</script>", "", text, flags=re.DOTALL)
clean = re.sub(r"<style[^>]*>.*?</style>", "", clean, flags=re.DOTALL)
clean = re.sub(r"<[^>]+>", " ", clean)
clean = re.sub(r"\s+", " ", clean).strip()
return clean[:8000] # Limit to 8K chars
# ---------------------------------------------------------------------------
# Tool implementations
# ---------------------------------------------------------------------------
TOOLS = [
{
"name": "scrape_datasheet",
"description": "Scrape a component datasheet from a URL. Extracts text content from the page or PDF link.",
"inputSchema": {
"type": "object",
"properties": {
"url": {"type": "string", "description": "URL of the datasheet page (e.g., ti.com, st.com, espressif.com)"},
"component": {"type": "string", "description": "Component name/part number for context"},
},
"required": ["url"],
},
},
{
"name": "search_components",
"description": "Search for electronic components — find alternatives, check availability, get specs.",
"inputSchema": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Component search query (e.g., 'STM32F4 LQFP48 3.3V')"},
"source": {
"type": "string",
"enum": ["lcsc", "octopart", "digikey"],
"description": "Search source (default: lcsc for JLCPCB compatibility)",
},
},
"required": ["query"],
},
},
{
"name": "scrape_forum",
"description": "Scrape electronics forum threads for technical knowledge (EEVBlog, StackOverflow Electronics, Reddit).",
"inputSchema": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Search query for forum posts"},
"source": {
"type": "string",
"enum": ["eevblog", "stackoverflow", "reddit"],
"description": "Forum to search (default: stackoverflow)",
},
"max_results": {"type": "integer", "description": "Max results (default: 5)"},
},
"required": ["query"],
},
},
{
"name": "scrape_docs",
"description": "Scrape technical documentation (Espressif ESP-IDF, STM32 HAL, KiCad docs, PlatformIO).",
"inputSchema": {
"type": "object",
"properties": {
"url": {"type": "string", "description": "Documentation URL to scrape"},
"topic": {"type": "string", "description": "Topic to focus on (filters content)"},
},
"required": ["url"],
},
},
{
"name": "monitor_updates",
"description": "Check a URL for updates since last visit. Useful for tracking component EOL notices, library updates.",
"inputSchema": {
"type": "object",
"properties": {
"url": {"type": "string", "description": "URL to monitor"},
"label": {"type": "string", "description": "Human label for this monitor (e.g., 'ESP32-S3 errata')"},
},
"required": ["url"],
},
},
{
"name": "feed_dataset",
"description": "Format scraped data for Mascarade HuggingFace datasets (SPICE, KiCad, STM32, EMC).",
"inputSchema": {
"type": "object",
"properties": {
"domain": {
"type": "string",
"enum": ["spice", "kicad", "stm32", "emc", "esp32", "pcb", "general"],
"description": "Target dataset domain",
},
"instruction": {"type": "string", "description": "The instruction/question"},
"response": {"type": "string", "description": "The expected response/answer"},
"source_url": {"type": "string", "description": "Source URL of the data"},
},
"required": ["domain", "instruction", "response"],
},
},
]
async def handle_scrape_datasheet(args: dict) -> str:
url = args["url"]
component = args.get("component", "")
if APIFY_TOKEN:
try:
result = await _run_actor("apify/web-scraper", {
"startUrls": [{"url": url}],
"pageFunction": "async function pageFunction(context) { return { title: document.title, text: document.body.innerText.substring(0, 10000) }; }",
})
if result["status"] == "ok" and result.get("items"):
item = result["items"][0] if isinstance(result["items"], list) else result["items"]
return json.dumps({"component": component, "title": item.get("title", ""), "content": item.get("text", "")[:6000], "source": url})
except Exception:
pass
# Fallback: direct scrape
text = await _direct_scrape(url)
return json.dumps({"component": component, "content": text[:6000], "source": url})
async def handle_search_components(args: dict) -> str:
query = args["query"]
source = args.get("source", "lcsc")
search_urls = {
"lcsc": f"https://www.lcsc.com/search?q={query.replace(' ', '+')}",
"octopart": f"https://octopart.com/search?q={query.replace(' ', '+')}",
"digikey": f"https://www.digikey.com/en/products/result?keywords={query.replace(' ', '+')}",
}
url = search_urls.get(source, search_urls["lcsc"])
text = await _direct_scrape(url)
return json.dumps({"query": query, "source": source, "url": url, "results": text[:4000]})
async def handle_scrape_forum(args: dict) -> str:
query = args["query"]
source = args.get("source", "stackoverflow")
max_results = args.get("max_results", 5)
if source == "stackoverflow":
api_url = f"https://api.stackexchange.com/2.3/search/advanced?order=desc&sort=relevance&q={query.replace(' ', '+')}&site=electronics&pagesize={max_results}&filter=withbody"
try:
data = await _fetch(api_url)
if isinstance(data, dict) and "items" in data:
results = []
for item in data["items"][:max_results]:
results.append({
"title": item.get("title", ""),
"link": item.get("link", ""),
"score": item.get("score", 0),
"answer_count": item.get("answer_count", 0),
"body": re.sub(r"<[^>]+>", "", item.get("body", ""))[:500],
})
return json.dumps({"query": query, "source": source, "results": results})
except Exception:
pass
# Fallback for eevblog/reddit
search_urls = {
"eevblog": f"https://www.eevblog.com/forum/index.php?action=search2;search={query.replace(' ', '+')}",
"reddit": f"https://www.reddit.com/r/AskElectronics/search/?q={query.replace(' ', '+')}&restrict_sr=1",
}
url = search_urls.get(source, search_urls["eevblog"])
text = await _direct_scrape(url)
return json.dumps({"query": query, "source": source, "results": text[:4000]})
async def handle_scrape_docs(args: dict) -> str:
url = args["url"]
topic = args.get("topic", "")
text = await _direct_scrape(url)
if topic:
# Filter paragraphs that mention the topic
paragraphs = text.split(". ")
relevant = [p for p in paragraphs if topic.lower() in p.lower()]
if relevant:
text = ". ".join(relevant[:20])
return json.dumps({"url": url, "topic": topic, "content": text[:6000]})
async def handle_monitor_updates(args: dict) -> str:
url = args["url"]
label = args.get("label", url)
cache_dir = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "apify-mcp" / "monitors"
cache_dir.mkdir(parents=True, exist_ok=True)
cache_file = cache_dir / re.sub(r"[^\w]", "_", url)[:100]
text = await _direct_scrape(url)
content_hash = hashlib.md5(text.encode()).hexdigest()
changed = False
if cache_file.exists():
old_hash = cache_file.read_text().strip()
changed = old_hash != content_hash
cache_file.write_text(content_hash)
return json.dumps({"url": url, "label": label, "changed": changed, "preview": text[:500]})
async def handle_feed_dataset(args: dict) -> str:
domain = args["domain"]
instruction = args["instruction"]
response = args["response"]
source_url = args.get("source_url", "")
entry = {
"instruction": instruction,
"output": response,
"domain": domain,
"source": source_url,
}
# Append to local JSONL file
dataset_dir = Path(os.environ.get("MASCARADE_DIR", ".")) / "datasets" / domain
dataset_dir.mkdir(parents=True, exist_ok=True)
dataset_file = dataset_dir / "scraped.jsonl"
with open(dataset_file, "a") as f:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
with open(dataset_file) as fcount:
count = sum(1 for _ in fcount)
return json.dumps({"status": "ok", "domain": domain, "file": str(dataset_file), "total_entries": count})
async def handle_get_runtime_info(_args: dict) -> str:
apify_key = os.environ.get("APIFY_API_KEY", "")
mode = "apify-api" if apify_key and len(apify_key) > 10 else "direct-scrape"
return json.dumps({
"ok": True,
"mode": mode,
"apify_key_configured": bool(apify_key),
"tools": [t["name"] for t in TOOLS],
})
async def handle_fetch_espressif_docs(args: dict) -> str:
"""Fetch Espressif ESP-IDF / ESP32 documentation for a given topic."""
topic = args.get("topic", "")
chip = args.get("chip", "esp32s3")
base = f"https://docs.espressif.com/projects/esp-idf/en/latest/{chip}"
url = args.get("url", base)
return await handle_scrape_docs({"url": url, "topic": topic})
async def handle_fetch_platformio_registry(args: dict) -> str:
"""Fetch PlatformIO library registry entry for a library name or ID."""
library = args.get("library", "")
url = args.get("url", f"https://registry.platformio.org/search?q={library}")
return await handle_scrape_docs({"url": url, "topic": library})
async def handle_fetch_kicad_library_info(args: dict) -> str:
"""Fetch KiCad component library information (symbols, footprints)."""
component = args.get("component", "")
url = args.get("url", f"https://www.kicad.org/libraries/search/?q={component}")
return await handle_scrape_docs({"url": url, "topic": component})
async def handle_ingest_to_rag(args: dict) -> str:
"""Ingest scraped content into the Mascarade RAG pipeline."""
collection = args.get("collection", "kb-firmware")
content = args.get("content", "")
source = args.get("source", "apify-scrape")
if not content:
return json.dumps({"ok": False, "error": "content is required"})
rag_url = os.environ.get("MASCARADE_URL", "http://localhost:8100")
payload = {"collection": collection, "text": content, "metadata": {"source": source}}
try:
import urllib.request as _req
data = json.dumps(payload).encode()
req = _req.Request(f"{rag_url}/v1/rag/ingest", data=data,
headers={"Content-Type": "application/json"}, method="POST")
with _req.urlopen(req, timeout=30) as r:
resp = json.loads(r.read().decode())
return json.dumps({"ok": True, "collection": collection, "response": resp})
except Exception as exc:
return json.dumps({"ok": False, "error": str(exc)})
# Append Kill_LIFE-specific tools to the shared list.
TOOLS.extend([
{
"name": "get_runtime_info",
"description": "Return Apify MCP runtime info: mode (apify-api vs direct-scrape), key status, registered tools.",
"inputSchema": {"type": "object", "properties": {}},
},
{
"name": "fetch_espressif_docs",
"description": "Fetch Espressif ESP-IDF / ESP32-S3 documentation for a topic or URL.",
"inputSchema": {
"type": "object",
"properties": {
"topic": {"type": "string", "description": "Documentation topic to fetch"},
"chip": {"type": "string", "description": "Target chip (default: esp32s3)"},
"url": {"type": "string", "description": "Override URL"},
},
},
},
{
"name": "fetch_platformio_registry",
"description": "Fetch PlatformIO library registry info for a library name.",
"inputSchema": {
"type": "object",
"properties": {
"library": {"type": "string", "description": "Library name or search query"},
"url": {"type": "string", "description": "Override URL"},
},
},
},
{
"name": "fetch_kicad_library_info",
"description": "Fetch KiCad component library info (symbols, footprints) for a component.",
"inputSchema": {
"type": "object",
"properties": {
"component": {"type": "string", "description": "Component name or search query"},
"url": {"type": "string", "description": "Override URL"},
},
},
},
{
"name": "ingest_to_rag",
"description": "Ingest scraped content into the Mascarade RAG pipeline (Qdrant).",
"inputSchema": {
"type": "object",
"properties": {
"collection": {"type": "string", "description": "RAG collection (e.g. kb-firmware, kb-kicad)"},
"content": {"type": "string", "description": "Text content to ingest"},
"source": {"type": "string", "description": "Source label (e.g. espressif-docs)"},
},
"required": ["content"],
},
},
])
HANDLERS = {
"scrape_datasheet": handle_scrape_datasheet,
"search_components": handle_search_components,
"scrape_forum": handle_scrape_forum,
"scrape_docs": handle_scrape_docs,
"monitor_updates": handle_monitor_updates,
"feed_dataset": handle_feed_dataset,
"get_runtime_info": handle_get_runtime_info,
"fetch_espressif_docs": handle_fetch_espressif_docs,
"fetch_platformio_registry": handle_fetch_platformio_registry,
"fetch_kicad_library_info": handle_fetch_kicad_library_info,
"ingest_to_rag": handle_ingest_to_rag,
}
# ---------------------------------------------------------------------------
# MCP stdio protocol loop
# ---------------------------------------------------------------------------
SERVER_INFO = {"name": "apify", "version": "1.0.0"}
async def handle_message(msg: dict) -> dict | None:
method = msg.get("method", "")
mid = msg.get("id")
if method == "initialize":
return make_response(mid, {
"protocolVersion": PROTOCOL_VERSION,
"capabilities": {"tools": {"listChanged": False}},
"serverInfo": SERVER_INFO,
})
if method == "notifications/initialized":
return None
if method == "tools/list":
return make_response(mid, {"tools": TOOLS})
if method == "tools/call":
params = msg.get("params", {})
name = params.get("name", "")
args = params.get("arguments", {})
handler = HANDLERS.get(name)
if not handler:
return make_response(mid, error_tool_result(f"Unknown tool: {name}", {}))
try:
result = await handler(args)
structured = json.loads(result) if isinstance(result, str) else result
return make_response(mid, ok_tool_result(result if isinstance(result, str) else json.dumps(result), structured if isinstance(structured, dict) else {}))
except Exception as exc:
return make_response(mid, error_tool_result(f"{type(exc).__name__}: {exc}", {}))
return make_error(mid, -32601, f"Method not found: {method}")
async def main() -> None:
while True:
msg = read_message()
if msg is None:
break
resp = await handle_message(msg)
if resp is not None:
write_message(resp)
if __name__ == "__main__":
asyncio.run(main())