Files
Kill_LIFE/tools/github_dispatch_mcp.py
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

257 lines
8.4 KiB
Python
Executable File

#!/usr/bin/env python3
"""Local MCP server for GitHub workflow_dispatch operations."""
from __future__ import annotations
import asyncio
import os
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,
)
ROOT = Path(__file__).resolve().parents[1]
MASCARADE_DIR = Path(os.environ.get("MASCARADE_DIR", ROOT.parent / "mascarade")).resolve()
MASCARADE_CORE_DIR = MASCARADE_DIR / "core"
if str(MASCARADE_CORE_DIR) not in sys.path:
sys.path.insert(0, str(MASCARADE_CORE_DIR))
GITHUB_DISPATCH_IMPORT_ERROR: str | None = None
try:
from mascarade.integrations.github_dispatch import ( # noqa: E402
DEFAULT_GITHUB_REPO,
GitHubDispatchAuthError,
GitHubDispatchClient,
GitHubDispatchError,
list_allowlisted_workflows,
)
except ModuleNotFoundError as exc: # pragma: no cover - exercised through smoke flow
GITHUB_DISPATCH_IMPORT_ERROR = str(exc)
DEFAULT_GITHUB_REPO = os.getenv("GITHUB_DISPATCH_REPO", "electron-rare/Kill_LIFE")
class GitHubDispatchError(RuntimeError):
pass
class GitHubDispatchAuthError(GitHubDispatchError):
pass
class GitHubDispatchClient:
async def dispatch_workflow(self, workflow_file: str, ref: str | None = None, inputs: dict[str, Any] | None = None) -> dict[str, Any]:
token = os.getenv("KILL_LIFE_GITHUB_TOKEN") or os.getenv("GITHUB_TOKEN")
if not token:
raise GitHubDispatchAuthError(
"GitHub token not configured: set KILL_LIFE_GITHUB_TOKEN or GITHUB_TOKEN"
)
raise GitHubDispatchError(
f"Mascarade github_dispatch integration unavailable: {GITHUB_DISPATCH_IMPORT_ERROR}"
)
async def get_dispatch_status(self, dispatch_id: str) -> dict[str, Any]:
raise GitHubDispatchError(
f"Mascarade github_dispatch integration unavailable: {GITHUB_DISPATCH_IMPORT_ERROR}"
)
async def close(self) -> None:
return None
def list_allowlisted_workflows() -> list[str]:
workflows_dir = ROOT / ".github" / "workflows"
if not workflows_dir.is_dir():
return ["repo_state.yml"]
return sorted(path.name for path in workflows_dir.glob("*.yml"))
TOOLS = [
{
"name": "list_allowlisted_workflows",
"description": "List the GitHub Actions workflows allowed for dispatch in this workspace.",
"inputSchema": {"type": "object", "properties": {}},
},
{
"name": "dispatch_workflow",
"description": "Dispatch an allowlisted GitHub Actions workflow.",
"inputSchema": {
"type": "object",
"properties": {
"workflow_file": {
"type": "string",
"description": "Allowlisted workflow file to dispatch",
},
"ref": {"type": "string", "description": "Git ref to dispatch against"},
"inputs": {
"type": "object",
"description": "Workflow dispatch inputs",
},
},
"required": ["workflow_file"],
},
},
{
"name": "get_dispatch_status",
"description": "Resolve the structured status of a previously dispatched workflow.",
"inputSchema": {
"type": "object",
"properties": {
"dispatch_id": {
"type": "string",
"description": "Dispatch identifier returned by dispatch_workflow",
}
},
"required": ["dispatch_id"],
},
},
]
async def tool_list_allowlisted_workflows(_: dict[str, Any]) -> dict[str, Any]:
workflows = list_allowlisted_workflows()
payload = {"ok": True, "repo": DEFAULT_GITHUB_REPO, "workflows": workflows}
return ok_tool_result(
f"{len(workflows)} allowlisted GitHub workflow(s)",
payload,
)
async def tool_dispatch_workflow(arguments: dict[str, Any]) -> dict[str, Any]:
workflow_file = str(arguments.get("workflow_file", "")).strip()
ref = str(arguments.get("ref", "")).strip() or None
raw_inputs = arguments.get("inputs")
inputs = raw_inputs if isinstance(raw_inputs, dict) else None
if not workflow_file:
return error_tool_result(
"Missing required argument 'workflow_file'",
{
"ok": False,
"error": {
"code": "invalid_arguments",
"message": "workflow_file is required",
},
},
)
client = GitHubDispatchClient()
try:
payload = await client.dispatch_workflow(workflow_file, ref=ref, inputs=inputs)
return ok_tool_result(
f"Accepted GitHub dispatch for {payload['workflow_file']}",
{"ok": True, **payload},
)
except GitHubDispatchAuthError as exc:
return error_tool_result(
str(exc),
{"ok": False, "error": {"code": "missing_secret", "message": str(exc)}},
)
except GitHubDispatchError as exc:
return error_tool_result(
str(exc),
{"ok": False, "error": {"code": "dispatch_failed", "message": str(exc)}},
)
finally:
await client.close()
async def tool_get_dispatch_status(arguments: dict[str, Any]) -> dict[str, Any]:
dispatch_id = str(arguments.get("dispatch_id", "")).strip()
if not dispatch_id:
return error_tool_result(
"Missing required argument 'dispatch_id'",
{
"ok": False,
"error": {
"code": "invalid_arguments",
"message": "dispatch_id is required",
},
},
)
client = GitHubDispatchClient()
try:
payload = await client.get_dispatch_status(dispatch_id)
return ok_tool_result(
f"Dispatch {dispatch_id} status is {payload['status']}",
{"ok": True, **payload},
)
except GitHubDispatchError as exc:
return error_tool_result(
str(exc),
{"ok": False, "error": {"code": "status_failed", "message": str(exc)}},
)
finally:
await client.close()
def serve_mcp() -> int:
while True:
request = read_message()
if request is None:
return 0
method = request.get("method")
request_id = request.get("id")
params = request.get("params") or {}
if method == "initialize":
write_message(
make_response(
request_id,
{
"protocolVersion": PROTOCOL_VERSION,
"capabilities": {"tools": {"listChanged": False}},
"serverInfo": {"name": "github-dispatch", "version": "1.0.0"},
},
)
)
continue
if method == "notifications/initialized":
continue
if method == "ping":
write_message(make_response(request_id, {}))
continue
if method == "tools/list":
write_message(make_response(request_id, {"tools": TOOLS}))
continue
if method == "tools/call":
tool_name = params.get("name")
arguments = params.get("arguments") or {}
if tool_name == "list_allowlisted_workflows":
write_message(
make_response(request_id, asyncio.run(tool_list_allowlisted_workflows(arguments)))
)
continue
if tool_name == "dispatch_workflow":
write_message(
make_response(request_id, asyncio.run(tool_dispatch_workflow(arguments)))
)
continue
if tool_name == "get_dispatch_status":
write_message(
make_response(request_id, asyncio.run(tool_get_dispatch_status(arguments)))
)
continue
write_message(make_error(request_id, -32602, f"Unknown tool: {tool_name}"))
continue
if request_id is not None:
write_message(make_error(request_id, -32601, f"Method not found: {method}"))
if __name__ == "__main__":
raise SystemExit(serve_mcp())