@labwired/mcp
Advanced tools
+185
| /** | ||
| * On-disk example-lab catalog for the high-level `labwired_list_boards` / | ||
| * `labwired_run` (a.k.a. the deprecated `labwired_run_lab`) tools. | ||
| * | ||
| * Each entry is an EXAMPLE LAB — a chip + opinionated wiring + optional demo | ||
| * firmware — backed by a real `core/examples/<dir>/system.yaml` on disk that the | ||
| * local `labwired` CLI consumes via `--system`. | ||
| * | ||
| * SINGLE SOURCE OF TRUTH: `@labwired/board-config` owns board/chip IDENTITY, and | ||
| * chip identity here is DERIVED from and drift-guarded against it — every lab | ||
| * names a board-config `chip` (the module throws at import if it names a chip | ||
| * board-config doesn't know), and a lab whose id is a canonical board inherits | ||
| * that board's `name`/`description`. The old hand-copied per-entry chip_family / | ||
| * chip_yaml / name / description are gone. | ||
| * | ||
| * What stays REPO-LOCAL (board-config does not model on-disk example labs): the | ||
| * list of labs, their `core/examples/<dir>/system.yaml` wiring, the on-disk | ||
| * `core/configs/chips/<chip>.yaml` descriptor path the CLI reads, the `arch` | ||
| * display label, and the demo-firmware path. Paths are resolved relative to a | ||
| * repo-root anchor discovered at runtime by `findRepoRoot()` (walks up from the | ||
| * MCP server's __dirname looking for `core/configs/chips/`; falls back to env | ||
| * `LABWIRED_REPO_ROOT`). | ||
| */ | ||
| import { readFile } from 'node:fs/promises'; | ||
| import { dirname, join, resolve } from 'node:path'; | ||
| import { fileURLToPath } from 'node:url'; | ||
| import { existsSync } from 'node:fs'; | ||
| import { BOARDS as BOARD_CONFIG_BOARDS, getBoard as getBoardConfigBoard, boardChipId } from '@labwired/board-config'; | ||
| // The list itself is repo-local: these are on-disk example labs, which | ||
| // board-config deliberately does not model (it owns canonical boards, not labs). | ||
| // `stm32f103-blinky` IS a board-config board, so its name/description are omitted | ||
| // here and inherited from board-config — proving the single-source link. | ||
| const LAB_SPECS = [ | ||
| { | ||
| id: 'stm32f103-blinky', | ||
| chip: 'stm32f103', | ||
| arch: 'ARM Cortex-M3', | ||
| example: 'demo-blinky', | ||
| demo_firmware: 'core/target/thumbv7m-none-eabi/release/demo-blinky', | ||
| }, | ||
| { | ||
| id: 'ntc-thermistor-lab', | ||
| chip: 'stm32f103', | ||
| arch: 'ARM Cortex-M3', | ||
| example: 'ntc-thermistor-lab', | ||
| name: 'NTC Thermistor', | ||
| description: 'Analog temperature sensor via Steinhart-Hart math.', | ||
| }, | ||
| { | ||
| id: 'ssd1306-hello-lab', | ||
| chip: 'stm32f103', | ||
| arch: 'ARM Cortex-M3', | ||
| example: 'ssd1306-hello-lab', | ||
| name: 'SSD1306 OLED', | ||
| description: '128×64 monochrome OLED — full SSD1306 mode machine and framebuffer.', | ||
| }, | ||
| { | ||
| id: 'bme280-weather-lab', | ||
| chip: 'stm32f103', | ||
| arch: 'ARM Cortex-M3', | ||
| example: 'bme280-weather-lab', | ||
| name: 'BME280 Weather', | ||
| description: 'Temperature, humidity, pressure over I²C.', | ||
| }, | ||
| { | ||
| id: 'ili9341-tft-lab', | ||
| chip: 'stm32f103', | ||
| arch: 'ARM Cortex-M3', | ||
| example: 'ili9341-tft-lab', | ||
| name: 'ILI9341 TFT Color', | ||
| description: '320×240 SPI TFT — model state machine, 16-bit framebuffer.', | ||
| }, | ||
| { | ||
| id: 'epaper-tricolor-lab', | ||
| chip: 'stm32f103', | ||
| arch: 'ARM Cortex-M3', | ||
| example: 'epaper-tricolor-lab', | ||
| name: 'E-Paper 2.9" Tri-color', | ||
| description: 'SSD1680 tri-color e-paper — two-plane composition, byte-identical to silicon.', | ||
| }, | ||
| { | ||
| id: 'mpu6050-sensor-lab', | ||
| chip: 'stm32f103', | ||
| arch: 'ARM Cortex-M3', | ||
| example: 'mpu6050-sensor-lab', | ||
| name: 'MPU6050 IMU', | ||
| description: '6-DoF accelerometer + gyroscope over I²C.', | ||
| }, | ||
| { | ||
| id: 'adxl345-sensor-lab', | ||
| chip: 'stm32f103', | ||
| arch: 'ARM Cortex-M3', | ||
| example: 'adxl345-sensor-lab', | ||
| name: 'ADXL345 Sensor Lab', | ||
| description: '3-axis accelerometer over I²C.', | ||
| }, | ||
| ]; | ||
| // Fail-loud drift guard: every lab's chip must be a real @labwired/board-config | ||
| // chip, so a chip id can never silently diverge from the single source of truth. | ||
| const KNOWN_CHIPS = new Set(BOARD_CONFIG_BOARDS.map((b) => b.chip)); | ||
| for (const spec of LAB_SPECS) { | ||
| if (!KNOWN_CHIPS.has(spec.chip)) { | ||
| throw new Error(`boards.ts: lab "${spec.id}" references chip "${spec.chip}", which is not a ` + | ||
| `@labwired/board-config chip. Add it to board-config or fix the chip id.`); | ||
| } | ||
| } | ||
| /** Project a repo-local lab spec into a full catalog entry, sourcing identity | ||
| * from board-config where the lab id is a canonical board and resolving on-disk | ||
| * YAML paths by repo convention. */ | ||
| function toEntry(spec) { | ||
| const canonical = getBoardConfigBoard(spec.id); | ||
| const chip = boardChipId(spec.chip); | ||
| return { | ||
| id: spec.id, | ||
| name: spec.name ?? canonical?.name ?? spec.id, | ||
| chip_family: chip, | ||
| arch: spec.arch, | ||
| description: spec.description ?? canonical?.description ?? '', | ||
| chip_yaml: `core/configs/chips/${chip}.yaml`, | ||
| system_yaml: `core/examples/${spec.example}/system.yaml`, | ||
| ...(spec.demo_firmware ? { demo_firmware: spec.demo_firmware } : {}), | ||
| }; | ||
| } | ||
| /** The example-lab catalog, derived from LAB_SPECS + board-config identity. */ | ||
| export const BOARDS = LAB_SPECS.map(toEntry); | ||
| /** Try to find the repo root by walking up from this file until core/configs is found. */ | ||
| function findRepoRoot() { | ||
| const fromEnv = process.env.LABWIRED_REPO_ROOT; | ||
| if (fromEnv && existsSync(join(fromEnv, 'core/configs/chips'))) | ||
| return resolve(fromEnv); | ||
| let cursor = dirname(fileURLToPath(import.meta.url)); | ||
| for (let i = 0; i < 8; i++) { | ||
| if (existsSync(join(cursor, 'core/configs/chips'))) | ||
| return cursor; | ||
| const parent = dirname(cursor); | ||
| if (parent === cursor) | ||
| break; | ||
| cursor = parent; | ||
| } | ||
| return null; | ||
| } | ||
| const REPO_ROOT = findRepoRoot(); | ||
| export function getBoard(id) { | ||
| return BOARDS.find((b) => b.id === id); | ||
| } | ||
| export async function readBoardYamls(board) { | ||
| if (!REPO_ROOT) { | ||
| throw new Error('Could not locate LabWired repo root. Set LABWIRED_REPO_ROOT env var to the absolute path of your labwired checkout (e.g. /home/you/Projects/labwired).'); | ||
| } | ||
| const chipYaml = await readFile(join(REPO_ROOT, board.chip_yaml), 'utf-8'); | ||
| const systemYaml = await readFile(join(REPO_ROOT, board.system_yaml), 'utf-8'); | ||
| return { chipYaml, systemYaml }; | ||
| } | ||
| /** Absolute on-disk path to the board's system YAML in the labwired repo. */ | ||
| export function boardSystemYamlPath(board) { | ||
| if (!REPO_ROOT) { | ||
| throw new Error('Could not locate LabWired repo root. Set LABWIRED_REPO_ROOT env var.'); | ||
| } | ||
| return join(REPO_ROOT, board.system_yaml); | ||
| } | ||
| /** Absolute on-disk path to the board's chip descriptor YAML. */ | ||
| export function boardChipYamlPath(board) { | ||
| if (!REPO_ROOT) { | ||
| throw new Error('Could not locate LabWired repo root. Set LABWIRED_REPO_ROOT env var.'); | ||
| } | ||
| return join(REPO_ROOT, board.chip_yaml); | ||
| } | ||
| export async function readDemoFirmware(board) { | ||
| if (!board.demo_firmware || !REPO_ROOT) | ||
| return null; | ||
| try { | ||
| return await readFile(join(REPO_ROOT, board.demo_firmware)); | ||
| } | ||
| catch { | ||
| return null; | ||
| } | ||
| } | ||
| export function listBoards(filter) { | ||
| if (!filter) | ||
| return BOARDS; | ||
| const q = filter.toLowerCase(); | ||
| return BOARDS.filter((b) => b.id.toLowerCase().includes(q) || | ||
| b.name.toLowerCase().includes(q) || | ||
| b.chip_family.toLowerCase().includes(q)); | ||
| } |
| // Content-addressed firmware ref currency for the stdio MCP surface. | ||
| // | ||
| // The Lab document pins firmware by a `sha256:<hex>` content hash | ||
| // (`firmware.artifact.ref`) rather than carrying base64 bytes. This module | ||
| // resolves such a ref by fetching the raw ELF from the hosted blob endpoint and | ||
| // base64-encoding it, so it slots straight into the existing base64 firmware | ||
| // path shared by labwired_run / labwired_verify. One surface, ref-first; inline | ||
| // base64 keeps working unchanged. | ||
| import { isLabWiredArtifactRef, parseArtifactRef } from '@labwired/board-config'; | ||
| /** Default hosted API base; overridable via LABWIRED_API for local/staging. */ | ||
| export function defaultApiBase() { | ||
| return process.env.LABWIRED_API ?? 'https://api.labwired.com'; | ||
| } | ||
| /** | ||
| * Resolve a `sha256:<hex>` firmware ref to base64 ELF bytes by fetching the raw | ||
| * (application/octet-stream) blob from `${apiBase}/v1/blobs/sha256/<hex>`. | ||
| * Throws on a malformed ref or a non-200 response. | ||
| */ | ||
| export async function resolveFirmwareRef(ref, apiBase) { | ||
| if (!isLabWiredArtifactRef(ref)) { | ||
| throw new Error(`invalid firmware_ref: ${JSON.stringify(ref)} (expected "sha256:<64 hex>")`); | ||
| } | ||
| const { hex } = parseArtifactRef(ref); | ||
| const base = (apiBase ?? defaultApiBase()).replace(/\/+$/, ''); | ||
| const url = `${base}/v1/blobs/sha256/${hex}`; | ||
| const res = await fetch(url); | ||
| if (!res.ok) { | ||
| throw new Error(`failed to resolve firmware_ref ${ref}: ${res.status} ${res.statusText} (${url})`); | ||
| } | ||
| const buf = await res.arrayBuffer(); | ||
| return Buffer.from(buf).toString('base64'); | ||
| } | ||
| /** | ||
| * Pull a firmware ref out of a tool's arguments: a top-level `firmware_ref` | ||
| * takes precedence, else a lab document's `diagram.firmware.artifact.ref`. | ||
| * Returns undefined when neither is present. | ||
| */ | ||
| export function firmwareRefFromArgs(args) { | ||
| if (!args || typeof args !== 'object') | ||
| return undefined; | ||
| const a = args; | ||
| if (typeof a.firmware_ref === 'string' && a.firmware_ref.trim()) | ||
| return a.firmware_ref.trim(); | ||
| const diagram = a.diagram; | ||
| if (diagram && typeof diagram === 'object') { | ||
| const firmware = diagram.firmware; | ||
| if (firmware && typeof firmware === 'object') { | ||
| const artifact = firmware.artifact; | ||
| if (artifact && typeof artifact === 'object') { | ||
| const ref = artifact.ref; | ||
| if (typeof ref === 'string' && ref.trim()) | ||
| return ref.trim(); | ||
| } | ||
| } | ||
| } | ||
| return undefined; | ||
| } |
| export const AGENT_HARDWARE_LOOP_URI = 'labwired://guides/agent-hardware-loop'; | ||
| export const AGENT_HARDWARE_LOOP_MIME = 'text/markdown'; | ||
| const AGENT_HARDWARE_LOOP_TEXT = `# LabWired agent hardware loop | ||
| Use LabWired as a deterministic virtual hardware lab for firmware work. | ||
| 1. Choose a board with \`labwired_list_boards\`. | ||
| 2. Validate custom system YAML with \`labwired_validate_system\`. | ||
| 3. Build or update the diagram and validate it with \`labwired_validate_diagram\`. | ||
| 4. Compile firmware locally and pass the ELF as base64. | ||
| 5. Run with \`labwired_run\` for a preconfigured board, or \`labwired_verify\` with an oracle for a gated proof. | ||
| 6. Inspect snapshots with \`labwired_inspect_run\`. | ||
| 7. Use \`labwired_fuzz\` when firmware exposes the fuzz contract. | ||
| 8. Iterate until serial, GPIO, cycle counts, and stop reasons match the intended behavior. | ||
| The local MCP server shells out to the \`labwired\` CLI. Set \`LABWIRED_CLI\` or \`LABWIRED_REPO_ROOT\` when running outside a checkout. | ||
| `; | ||
| export const RESOURCES = [ | ||
| { | ||
| uri: AGENT_HARDWARE_LOOP_URI, | ||
| name: 'labwired-agent-hardware-loop', | ||
| mimeType: AGENT_HARDWARE_LOOP_MIME, | ||
| description: 'Guide for using LabWired as an agent-driven virtual hardware lab.', | ||
| }, | ||
| ]; | ||
| export function getResource(uri) { | ||
| if (uri !== AGENT_HARDWARE_LOOP_URI) | ||
| return null; | ||
| return { | ||
| uri, | ||
| mimeType: AGENT_HARDWARE_LOOP_MIME, | ||
| text: AGENT_HARDWARE_LOOP_TEXT, | ||
| }; | ||
| } |
| import { rankTools as rankToolsShared } from '@labwired/board-config'; | ||
| import { toolAnnotations, toolTitle } from './tool-metadata.js'; | ||
| export const SEARCH_TOOLS_TOOL_NAME = 'labwired_search_tools'; | ||
| export const AGENT_HARDWARE_LOOP_GUIDE_URI = 'labwired://guides/agent-hardware-loop'; | ||
| export const LOCAL_AGENT_WORKFLOW = [ | ||
| 'labwired_list_boards', | ||
| 'labwired_validate_diagram', | ||
| 'labwired_compile_diagram', | ||
| 'labwired_run', | ||
| 'labwired_inspect_run', | ||
| ]; | ||
| export const SEARCH_TOOLS_TOOL = { | ||
| name: SEARCH_TOOLS_TOOL_NAME, | ||
| description: 'Search the LabWired MCP tool catalog by keyword and return relevant tool definitions. Use this to find board discovery, diagram validation, firmware run/verify, fuzzing, and snapshot tools.', | ||
| inputSchema: { | ||
| type: 'object', | ||
| required: ['query'], | ||
| properties: { | ||
| query: { | ||
| type: 'string', | ||
| description: 'Keywords describing the LabWired capability you need, e.g. "diagram validation" or "run firmware".', | ||
| }, | ||
| limit: { | ||
| type: 'integer', | ||
| minimum: 1, | ||
| maximum: 25, | ||
| description: 'Maximum number of tool definitions to return. Defaults to 8.', | ||
| }, | ||
| }, | ||
| }, | ||
| }; | ||
| /** BM25 ranking lives in @labwired/board-config so stdio + hosted can't drift. | ||
| * Inject stdio's title/annotation lookups. */ | ||
| export function rankTools(query, tools, limit = 8) { | ||
| return rankToolsShared(query, tools, { title: toolTitle, annotations: toolAnnotations }, limit); | ||
| } |
| /** | ||
| * In-memory snapshot store for `labwired_inspect_run`. Lets the agent fetch | ||
| * detailed state slices (registers, memory, peripheral) without re-shipping | ||
| * full state through `labwired_run`'s return. | ||
| * | ||
| * Lifecycle: store entries when a run completes, evict after TTL or when over | ||
| * MAX_ENTRIES (LRU). The MCP server process holds these; they don't survive a | ||
| * restart. Agents that want durable snapshots must capture full state in their | ||
| * own memory. | ||
| */ | ||
| const TTL_MS = 10 * 60 * 1000; // 10 minutes | ||
| const MAX_ENTRIES = 50; | ||
| const store = new Map(); | ||
| /** ULID-ish opaque id; cryptographic strength not required. */ | ||
| function newId() { | ||
| return ('snap_' + | ||
| Math.random().toString(36).slice(2, 10) + | ||
| Math.random().toString(36).slice(2, 10)); | ||
| } | ||
| function evictExpired() { | ||
| const now = Date.now(); | ||
| for (const [id, e] of store) { | ||
| if (now - e.snapshot.created_at > TTL_MS) | ||
| store.delete(id); | ||
| } | ||
| } | ||
| function evictLruIfNeeded() { | ||
| if (store.size <= MAX_ENTRIES) | ||
| return; | ||
| const sorted = [...store.entries()].sort((a, b) => a[1].last_access - b[1].last_access); | ||
| const toEvict = sorted.length - MAX_ENTRIES; | ||
| for (let i = 0; i < toEvict; i++) | ||
| store.delete(sorted[i][0]); | ||
| } | ||
| export function putSnapshot(snapshot) { | ||
| evictExpired(); | ||
| const id = newId(); | ||
| store.set(id, { snapshot, last_access: Date.now() }); | ||
| evictLruIfNeeded(); | ||
| return id; | ||
| } | ||
| export function getSnapshot(id) { | ||
| evictExpired(); | ||
| const e = store.get(id); | ||
| if (!e) | ||
| return null; | ||
| e.last_access = Date.now(); | ||
| return e.snapshot; | ||
| } | ||
| export function snapshotCount() { | ||
| return store.size; | ||
| } |
| const READ_ONLY_TOOLS = new Set([ | ||
| 'labwired_search_tools', | ||
| 'labwired_catalog', | ||
| 'labwired_validate_system', | ||
| 'labwired_list_boards', | ||
| 'labwired_inspect_run', | ||
| 'labwired_validate_diagram', | ||
| ]); | ||
| // labwired_define_component is intentionally absent from READ_ONLY_TOOLS: | ||
| // it writes a spec file to .labwired/components/. | ||
| const TITLES = { | ||
| labwired_catalog: 'Catalog', | ||
| labwired_validate_system: 'Validate System', | ||
| labwired_list_boards: 'List Boards', | ||
| labwired_run: 'Run', | ||
| labwired_verify: 'Verify', | ||
| labwired_fuzz: 'Fuzz Firmware', | ||
| labwired_inspect_run: 'Inspect Run', | ||
| labwired_validate_diagram: 'Validate Diagram', | ||
| labwired_search_tools: 'Search Tools', | ||
| labwired_define_component: 'Define Component', | ||
| labwired_compile_diagram: 'Compile Diagram', | ||
| labwired_ingest_svd: 'Ingest SVD', | ||
| }; | ||
| export function toolTitle(name) { | ||
| return TITLES[name] ?? name | ||
| .replace(/^labwired_/, '') | ||
| .split('_') | ||
| .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) | ||
| .join(' '); | ||
| } | ||
| export function toolAnnotations(name) { | ||
| const title = toolTitle(name); | ||
| const readOnly = READ_ONLY_TOOLS.has(name); | ||
| return { | ||
| title, | ||
| readOnlyHint: readOnly, | ||
| destructiveHint: false, | ||
| ...(readOnly ? {} : { openWorldHint: true }), | ||
| }; | ||
| } | ||
| export function decorateTools(tools) { | ||
| return tools.map((tool) => { | ||
| const title = toolTitle(tool.name); | ||
| return { | ||
| ...tool, | ||
| title, | ||
| annotations: toolAnnotations(tool.name), | ||
| }; | ||
| }); | ||
| } |
+289
-28
@@ -34,43 +34,100 @@ import { execFile } from 'node:child_process'; | ||
| } | ||
| export async function runSimulation(opts) { | ||
| const work = await mkdtemp(join(tmpdir(), 'labwired-mcp-')); | ||
| /** | ||
| * Run an Arduino-ESP32 firmware ELF against the hardware declared in the system | ||
| * manifest (the panel + its CS/DC pins are attached via `--system`). Boots the | ||
| * firmware and reports what the attached device actually did — e.g. an e-paper | ||
| * panel's refresh generation and how much ink reached it (the deterministic | ||
| * HARDWARE ORACLE). Uses the generic `arduino-esp32` path, which works for ANY | ||
| * symbol-bearing Arduino-ESP32 build (the agent's compiled firmware always has | ||
| * symbols) — NOT the `agentdeck` preset, whose hardcoded addresses fit only one | ||
| * firmware. (Profiles are a transitional thunk-selector; the end state is "load | ||
| * the binary for the chip and run real" with no profile at all.) | ||
| */ | ||
| export async function runDeviceCapture(opts) { | ||
| const work = await mkdtemp(join(tmpdir(), 'labwired-mcp-dev-')); | ||
| try { | ||
| const firmwarePath = join(work, 'firmware.elf'); | ||
| const systemPath = join(work, 'system.yaml'); | ||
| const scriptPath = join(work, 'script.yaml'); | ||
| const outputDir = join(work, 'out'); | ||
| const outPath = join(work, 'snap.lwrs'); | ||
| await writeFile(firmwarePath, Buffer.from(opts.firmwareBase64, 'base64')); | ||
| await writeFile(systemPath, opts.systemYaml); | ||
| await writeFile(scriptPath, opts.scriptYaml); | ||
| const steps = Math.min(opts.steps ?? 45_000_000, 50_000_000); | ||
| const { stderr, exitCode } = await runCli([ | ||
| 'snapshot', 'capture', | ||
| '--firmware', firmwarePath, | ||
| '--system', systemPath, | ||
| '--profile', opts.profile ?? 'arduino-esp32', | ||
| '--steps', String(steps), | ||
| '--output', outPath, | ||
| '--progress-every', '0', | ||
| ]); | ||
| // "labwired-cli snapshot: panel (ssd1680|uc8151d) state — refresh_generation=N, power_on=B, black-plane non-FF bytes=M/T" | ||
| const m = stderr.match(/panel \((ssd1680|uc8151d)\) state[^\n]*refresh_generation=(\d+)[^\n]*black-plane non-FF bytes=(\d+)\/(\d+)/); | ||
| const refresh_generation = m ? parseInt(m[2], 10) : 0; | ||
| const ink_bytes = m ? parseInt(m[3], 10) : 0; | ||
| return { | ||
| exit_code: exitCode, | ||
| painted: refresh_generation >= 1 && ink_bytes > 0, | ||
| panel: m ? m[1] : null, | ||
| refresh_generation, | ||
| ink_bytes, | ||
| total_bytes: m ? parseInt(m[4], 10) : 0, | ||
| readout: m ? m[0] : '(no panel readout — firmware may not drive a display)', | ||
| stderr_excerpt: stderr.slice(-3000), | ||
| }; | ||
| } | ||
| finally { | ||
| await rm(work, { recursive: true, force: true }).catch(() => { }); | ||
| } | ||
| } | ||
| /** | ||
| * Coverage-guided fuzz a firmware ELF in the simulator. The firmware must follow | ||
| * the fuzz contract (RAM length+data buffer, a verdict word). Returns the | ||
| * distinct crashing inputs found — replayable on real silicon (HIL-confirm). | ||
| */ | ||
| export async function fuzzFirmware(opts) { | ||
| const work = await mkdtemp(join(tmpdir(), 'labwired-fuzz-')); | ||
| try { | ||
| const firmwarePath = join(work, 'firmware.elf'); | ||
| const crashesPath = join(work, 'crashes.json'); | ||
| await writeFile(firmwarePath, opts.firmware); | ||
| const args = [ | ||
| 'test', | ||
| 'fuzz', | ||
| '--chip', | ||
| opts.chipYamlPath, | ||
| '--system', | ||
| opts.systemYamlPath, | ||
| '--firmware', | ||
| firmwarePath, | ||
| '--system', | ||
| systemPath, | ||
| '--script', | ||
| scriptPath, | ||
| '--output-dir', | ||
| outputDir, | ||
| '--no-uart-stdout', | ||
| '--collect', | ||
| String(opts.collect ?? 8), | ||
| '--crashes-out', | ||
| crashesPath, | ||
| '--max-iters', | ||
| String(opts.maxIters ?? 200_000), | ||
| ]; | ||
| if (opts.maxCycles) { | ||
| args.push('--max-cycles', String(opts.maxCycles)); | ||
| } | ||
| const { stderr, exitCode } = await runCli(args); | ||
| let resultJson = null; | ||
| let uartLog = ''; | ||
| if (opts.seed !== undefined) | ||
| args.push('--seed', String(opts.seed)); | ||
| for (const h of opts.seedInputsHex ?? []) | ||
| args.push('--seed-input', h); | ||
| const c = opts.contract ?? {}; | ||
| if (c.inputLenAddr) | ||
| args.push('--input-len-addr', c.inputLenAddr); | ||
| if (c.inputDataAddr) | ||
| args.push('--input-data-addr', c.inputDataAddr); | ||
| if (c.verdictAddr) | ||
| args.push('--verdict-addr', c.verdictAddr); | ||
| if (c.doneMagic) | ||
| args.push('--done-magic', c.doneMagic); | ||
| if (c.faultMagic) | ||
| args.push('--fault-magic', c.faultMagic); | ||
| const { stdout, stderr, exitCode } = await runCli(args); | ||
| let crashes = []; | ||
| try { | ||
| resultJson = JSON.parse(await readFile(join(outputDir, 'result.json'), 'utf-8')); | ||
| crashes = JSON.parse(await readFile(crashesPath, 'utf-8')); | ||
| } | ||
| catch { | ||
| /* keep null when missing */ | ||
| /* no crashes file = no crash found */ | ||
| } | ||
| try { | ||
| uartLog = await readFile(join(outputDir, 'uart.log'), 'utf-8'); | ||
| } | ||
| catch { | ||
| /* keep empty when missing */ | ||
| } | ||
| return { resultJson, uartLog, stderr, exitCode, outputDir }; | ||
| return { exitCode, crashed: crashes.length > 0, crashes, stdout, stderr }; | ||
| } | ||
@@ -81,2 +138,60 @@ finally { | ||
| } | ||
| /** Builder base URL for HTTP endpoints (/compile, /run-example). */ | ||
| function builderBaseUrl() { | ||
| const raw = process.env.LABWIRED_COMPILE_URL ?? process.env.LABWIRED_BUILDER_URL; | ||
| return raw ? raw.replace(/\/$/, '') : undefined; | ||
| } | ||
| export async function runBuildViaBuilder(opts) { | ||
| const base = builderBaseUrl(); | ||
| if (!base) { | ||
| return { | ||
| ok: false, | ||
| passed: false, | ||
| exit_code: 1, | ||
| status: '', | ||
| stop_reason: '', | ||
| assertions: [], | ||
| verdict_lines: [], | ||
| uart_excerpt: '', | ||
| error: 'No builder URL configured. Set LABWIRED_COMPILE_URL (or LABWIRED_BUILDER_URL) ' + | ||
| 'to the labwired-builder base URL so the build can run server-side.', | ||
| }; | ||
| } | ||
| const secret = process.env.LABWIRED_BUILDER_SECRET ?? process.env.BUILDER_SECRET ?? ''; | ||
| const headers = { 'content-type': 'application/json' }; | ||
| if (secret) | ||
| headers['x-builder-secret'] = secret; | ||
| const controller = new AbortController(); | ||
| const timer = setTimeout(() => controller.abort(), 600_000); | ||
| try { | ||
| const resp = await fetch(`${base}/run-build`, { | ||
| method: 'POST', | ||
| headers, | ||
| body: JSON.stringify({ | ||
| firmware_base64: opts.firmwareBase64, | ||
| system_yaml: opts.systemYaml, | ||
| test_yaml: opts.testYaml, | ||
| }), | ||
| signal: controller.signal, | ||
| }); | ||
| const data = (await resp.json()); | ||
| return data; | ||
| } | ||
| catch (e) { | ||
| return { | ||
| ok: false, | ||
| passed: false, | ||
| exit_code: 1, | ||
| status: '', | ||
| stop_reason: '', | ||
| assertions: [], | ||
| verdict_lines: [], | ||
| uart_excerpt: '', | ||
| error: `builder /run-build unreachable: ${e instanceof Error ? e.message : String(e)}`, | ||
| }; | ||
| } | ||
| finally { | ||
| clearTimeout(timer); | ||
| } | ||
| } | ||
| export async function validateSystem(systemYaml) { | ||
@@ -110,1 +225,147 @@ const work = await mkdtemp(join(tmpdir(), 'labwired-mcp-validate-')); | ||
| } | ||
| // ─── High-level lab runner ───────────────────────────────────────────────── | ||
| // `runLab` is the agent-friendly tool surface. The agent picks a board, hands | ||
| // us an ELF, says "run it for N cycles". We synthesize the test script YAML, | ||
| // invoke `labwired test`, read result.json + uart.log + the gpio trace, and | ||
| // pack a structured response. | ||
| const GPIO_EVENT_CAP = 10_000; | ||
| const UART_CAP_BYTES = 256 * 1024; | ||
| /** | ||
| * Render a `stimuli:` YAML block for test-script schema 1.2. serde_yaml 0.9 | ||
| * spells the externally-tagged trigger with a YAML tag (`!after_cycles { … }`), | ||
| * matching how `faults:` write theirs — NOT a single-key map. | ||
| */ | ||
| export function renderStimuliBlock(stimuli) { | ||
| const lines = ['stimuli:']; | ||
| for (const s of stimuli) { | ||
| const target = s.component | ||
| ? `{ component: ${JSON.stringify(s.component)}, channel: ${JSON.stringify(s.channel)} }` | ||
| : `{ channel: ${JSON.stringify(s.channel)} }`; | ||
| lines.push(` - target: ${target}`); | ||
| if (s.afterCycles && s.afterCycles > 0) { | ||
| lines.push(` trigger: !after_cycles { cycles: ${Math.floor(s.afterCycles)} }`); | ||
| } | ||
| lines.push(` value: ${s.value}`); | ||
| } | ||
| return lines.join('\n'); | ||
| } | ||
| export async function runLab(opts) { | ||
| if (!opts.systemYamlPath && !opts.systemYaml) { | ||
| throw new Error('runLab: provide either systemYamlPath or systemYaml'); | ||
| } | ||
| const work = await mkdtemp(join(tmpdir(), 'labwired-mcp-lab-')); | ||
| try { | ||
| const firmwarePath = join(work, 'firmware.elf'); | ||
| const scriptPath = join(work, 'script.yaml'); | ||
| const outputDir = join(work, 'out'); | ||
| await writeFile(firmwarePath, opts.firmware); | ||
| // Use the on-disk path when available so relative `chip:` / `descriptor:` | ||
| // refs in the system YAML resolve correctly. Fall back to writing inline | ||
| // content into the tempdir (which breaks relative refs). | ||
| let systemPath; | ||
| if (opts.systemYamlPath) { | ||
| systemPath = opts.systemYamlPath; | ||
| } | ||
| else { | ||
| systemPath = join(work, 'system.yaml'); | ||
| await writeFile(systemPath, opts.systemYaml); | ||
| } | ||
| // Synthesize a minimal "run for N cycles" script. We assert nothing | ||
| // about stop_reason because only one stop reason can be true per run, | ||
| // and multiple expected_stop_reason assertions all AND together (the | ||
| // CLI's "normal stop" rule already treats max_cycles / max_steps / and | ||
| // no_progress as success at the exit-code level when assertions pass). | ||
| // `inputs:` is required by schema 1.0 even though `--firmware` / | ||
| // `--system` flags override it. | ||
| const maxCycles = opts.maxCycles ?? 10_000_000; | ||
| const hasStimuli = (opts.stimuli?.length ?? 0) > 0; | ||
| // Stimuli require schema 1.2. `no_progress_steps` would stop the run before | ||
| // an `after_cycles` stimulus fires (a driven sensor doesn't change the PC), | ||
| // so drop the stuck-detector whenever stimuli are present. | ||
| const scriptLines = [ | ||
| `schema_version: "${hasStimuli ? '1.2' : '1.0'}"`, | ||
| 'inputs:', | ||
| ` firmware: ${JSON.stringify(firmwarePath)}`, | ||
| ` system: ${JSON.stringify(systemPath)}`, | ||
| 'limits:', | ||
| ` max_steps: ${maxCycles}`, // required by schema | ||
| ` max_cycles: ${maxCycles}`, | ||
| ]; | ||
| if (!hasStimuli) | ||
| scriptLines.push(` no_progress_steps: 1000`); | ||
| scriptLines.push('assertions: []'); | ||
| if (hasStimuli) | ||
| scriptLines.push(renderStimuliBlock(opts.stimuli)); | ||
| const script = scriptLines.join('\n') + '\n'; | ||
| await writeFile(scriptPath, script); | ||
| const args = [ | ||
| 'test', | ||
| '--firmware', firmwarePath, | ||
| '--system', systemPath, | ||
| '--script', scriptPath, | ||
| '--output-dir', outputDir, | ||
| '--no-uart-stdout', | ||
| '--max-cycles', String(maxCycles), | ||
| ]; | ||
| const { stderr, exitCode } = await runCli(args); | ||
| // result.json + uart.log are always written by `test` mode (when the run starts) | ||
| let raw_result = null; | ||
| let serial_output = ''; | ||
| try { | ||
| raw_result = JSON.parse(await readFile(join(outputDir, 'result.json'), 'utf-8')); | ||
| } | ||
| catch { /* keep null */ } | ||
| try { | ||
| serial_output = await readFile(join(outputDir, 'uart.log'), 'utf-8'); | ||
| } | ||
| catch { /* keep empty */ } | ||
| const serial_truncated = serial_output.length > UART_CAP_BYTES; | ||
| if (serial_truncated) | ||
| serial_output = serial_output.slice(-UART_CAP_BYTES); | ||
| // Tease out structured fields from raw_result. Actual CLI schema: | ||
| // { stop_reason: "max_cycles"|"max_steps"|"no_progress"|..., | ||
| // cycles: number, cpu_state: { pc: number, ... }, ... } | ||
| let exit_reason; | ||
| let final_cycles; | ||
| let final_pc_hex; | ||
| if (raw_result && typeof raw_result === 'object') { | ||
| const r = raw_result; | ||
| if (typeof r.stop_reason === 'string') | ||
| exit_reason = r.stop_reason; | ||
| else if (typeof r.exit_reason === 'string') | ||
| exit_reason = r.exit_reason; // legacy | ||
| if (typeof r.cycles === 'number') | ||
| final_cycles = r.cycles; | ||
| else if (typeof r.total_cycles === 'number') | ||
| final_cycles = r.total_cycles; // legacy | ||
| const cpu = r.cpu_state; | ||
| if (cpu && typeof cpu === 'object' && typeof cpu.pc === 'number') { | ||
| const pc = cpu.pc; | ||
| final_pc_hex = `0x${pc.toString(16).toUpperCase().padStart(8, '0')}`; | ||
| } | ||
| else if (typeof r.final_pc === 'number') { | ||
| final_pc_hex = `0x${r.final_pc.toString(16).toUpperCase().padStart(8, '0')}`; | ||
| } | ||
| else if (typeof r.final_pc === 'string') { | ||
| final_pc_hex = r.final_pc; | ||
| } | ||
| } | ||
| return { | ||
| exit_code: exitCode, | ||
| exit_reason, | ||
| final_cycles, | ||
| final_pc_hex, | ||
| serial_output, | ||
| serial_truncated, | ||
| // GPIO capture is best-effort; the current `test` subcommand may not emit | ||
| // a gpio trace. Left as undefined until the CLI grows that flag. | ||
| gpio_events: undefined, | ||
| raw_result, | ||
| stderr, | ||
| }; | ||
| } | ||
| finally { | ||
| await rm(work, { recursive: true, force: true }).catch(() => { }); | ||
| } | ||
| } | ||
| void GPIO_EVENT_CAP; // reserved for when CLI emits gpio traces |
+1204
-54
| #!/usr/bin/env node | ||
| import { Server } from '@modelcontextprotocol/sdk/server/index.js'; | ||
| import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; | ||
| import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js'; | ||
| import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, } from '@modelcontextprotocol/sdk/types.js'; | ||
| import { z } from 'zod'; | ||
| import { listChips, runSimulation, validateSystem } from './cli.js'; | ||
| import { mkdtemp, writeFile, rm, mkdir, readFile } from 'node:fs/promises'; | ||
| import { tmpdir } from 'node:os'; | ||
| import { join, resolve } from 'node:path'; | ||
| import { listChips, validateSystem, runLab, fuzzFirmware, runCli, runDeviceCapture, runBuildViaBuilder } from './cli.js'; | ||
| import { listBoards, getBoard, readBoardYamls, boardSystemYamlPath, boardChipYamlPath, } from './boards.js'; | ||
| import { putSnapshot, getSnapshot } from './snapshots.js'; | ||
| import { compile, composeDiagnostics, describeBoard, evaluateOracle, oracleClauseCount } from '@labwired/board-config'; | ||
| import { AGENT_HARDWARE_LOOP_GUIDE_URI, LOCAL_AGENT_WORKFLOW, SEARCH_TOOLS_TOOL, SEARCH_TOOLS_TOOL_NAME, rankTools, } from './search-tools.js'; | ||
| import { decorateTools } from './tool-metadata.js'; | ||
| import { RESOURCES, getResource } from './resources.js'; | ||
| import { resolveFirmwareRef, firmwareRefFromArgs } from './firmware-ref.js'; | ||
| const SERVER_NAME = '@labwired/mcp'; | ||
| const SERVER_VERSION = '0.1.1'; | ||
| const SERVER_VERSION = '0.5.0'; | ||
| const CatalogInput = z.object({ | ||
@@ -15,14 +25,26 @@ filter: z | ||
| }); | ||
| const SimulateInput = z.object({ | ||
| firmware_base64: z | ||
| const ValidateInput = z.object({ | ||
| system_yaml: z.string().describe('Full contents of the System Manifest YAML to validate.'), | ||
| }); | ||
| const ListBoardsInput = z.object({ | ||
| filter: z.string().optional().describe('Substring filter on id / name / chip family.'), | ||
| }); | ||
| const RunLabInput = z.object({ | ||
| target: z | ||
| .string() | ||
| .describe('Base64-encoded ELF firmware image to load into the simulator.'), | ||
| system_yaml: z | ||
| .optional() | ||
| .describe('Board id / target from labwired_list_boards, e.g. "stm32f103-blinky". Canonical name for board_id.'), | ||
| board_id: z | ||
| .string() | ||
| .describe('Full contents of the System Manifest YAML (chip, peripherals, memory map). ' + | ||
| 'See https://github.com/w1ne/labwired for schema.'), | ||
| script_yaml: z | ||
| .optional() | ||
| .describe('Deprecated alias for `target` (board id from labwired_list_boards).'), | ||
| elf_base64: z | ||
| .string() | ||
| .describe('Test script YAML: max_cycles, breakpoints, assertions on UART/registers/memory. ' + | ||
| 'Example schema: core/docs/ci_test_runner.md.'), | ||
| .optional() | ||
| .describe('Base64-encoded ELF to run. Agent compiles locally and uploads. Optional if firmware_ref is given.'), | ||
| firmware_ref: z | ||
| .string() | ||
| .optional() | ||
| .describe('Content-addressed firmware ref (`sha256:<hex>` from labwired_compile_firmware). Resolved from ' + | ||
| 'the hosted blob store — no base64 to carry. Used when elf_base64 is omitted.'), | ||
| max_cycles: z | ||
@@ -32,11 +54,163 @@ .number() | ||
| .positive() | ||
| .max(100_000_000) | ||
| .optional() | ||
| .describe('Optional override for max cycles (takes precedence over script value).'), | ||
| .describe('Cycle budget (default 10M, hard cap 100M).'), | ||
| output: z | ||
| .enum(['summary', 'serial', 'peripherals', 'full']) | ||
| .optional() | ||
| .describe('Response-shape selector: summary | serial | peripherals | full. Default full.'), | ||
| stimuli: z | ||
| .array(z.object({ | ||
| channel: z | ||
| .string() | ||
| .describe('SimInput channel key to drive: "x"/"y"/"z" (accel), "distance", "temperature", …'), | ||
| value: z.number().describe("Value in the channel's engineering unit (g, mm, °C, …)."), | ||
| after_cycles: z | ||
| .number() | ||
| .int() | ||
| .nonnegative() | ||
| .optional() | ||
| .describe('Fire this many cycles into the run; omit or 0 → at start.'), | ||
| component: z | ||
| .string() | ||
| .optional() | ||
| .describe('Advisory device-name hint; resolution is by unique channel key.'), | ||
| })) | ||
| .optional() | ||
| .describe('Declarative input stimuli: drive a sensor mid-run, then keep going — e.g. run to steady ' + | ||
| 'state, then set the accelerometer X to 2 g and confirm the firmware reacts. Each reaches ' + | ||
| 'the sim through the generic SimInput path (no per-device wiring). Requires a driven input ' + | ||
| 'device on the board.'), | ||
| }).refine((v) => Boolean(v.target || v.board_id), { | ||
| message: 'Provide `target` (or the legacy `board_id`) — a board id from labwired_list_boards.', | ||
| }); | ||
| const ValidateInput = z.object({ | ||
| system_yaml: z.string().describe('Full contents of the System Manifest YAML to validate.'), | ||
| const DescribeBoardInput = z.object({ | ||
| board: z | ||
| .string() | ||
| .describe('Any accepted board/chip spelling (canonical board id, playground id, or chip family key).'), | ||
| }); | ||
| const server = new Server({ name: SERVER_NAME, version: SERVER_VERSION }, { capabilities: { tools: {} } }); | ||
| server.setRequestHandler(ListToolsRequestSchema, async () => ({ | ||
| tools: [ | ||
| // ─── Oracle / unmodeled-access helpers (mcp-unification §c) ───────────────── | ||
| // A minimal "execute and capture UART" test script for the oracle-block verify | ||
| // path: the run just boots and records serial so evaluateOracle can prove over | ||
| // it. Its inputs.firmware / inputs.system are overridden by the builder. | ||
| function synthCaptureTestYaml() { | ||
| return [ | ||
| 'schema_version: "1.0"', | ||
| 'inputs:', | ||
| ' firmware: firmware.elf', | ||
| ' system: system.yaml', | ||
| 'limits:', | ||
| ' max_steps: 45000000', | ||
| ' max_cycles: 45000000', | ||
| 'assertions: []', | ||
| '', | ||
| ].join('\n'); | ||
| } | ||
| /** Best-effort scan for first-class unmodeled-access faults in a stdio CLI run. | ||
| * NOTE: the `labwired test` / `snapshot capture` CLI path that stdio uses does | ||
| * NOT emit a structured unmodeled-access event — only the builder HTTP `/run` | ||
| * path (services/labwired-builder/src/run.ts) does. So this only catches the | ||
| * free-text signal if the CLI happens to print it; absence here is a core/CLI | ||
| * gap, not proof the firmware touched nothing unmodeled. */ | ||
| function scanUnmodeledAccess(...texts) { | ||
| const hits = []; | ||
| const re = /(unmodel+ed|unimplemented|unmapped)\b[^\n]*/gi; | ||
| for (const t of texts) { | ||
| if (!t) | ||
| continue; | ||
| for (const m of t.matchAll(re)) | ||
| hits.push(m[0].trim()); | ||
| } | ||
| return Array.from(new Set(hits)).slice(0, 20); | ||
| } | ||
| const FuzzInput = z.object({ | ||
| board_id: z | ||
| .string() | ||
| .describe('Board id from labwired_list_boards (resolves chip + system YAML).'), | ||
| elf_base64: z | ||
| .string() | ||
| .describe('Base64-encoded fuzz-target ELF. Must follow the fuzz contract: read input ' + | ||
| 'length+bytes from RAM, write DONE/FAULT to a verdict word. Agent compiles locally.'), | ||
| max_iters: z.number().int().positive().max(10_000_000).optional() | ||
| .describe('Max fuzzing iterations (default 200k).'), | ||
| seed: z.number().int().optional().describe('RNG seed — fuzzing is deterministic for a fixed seed.'), | ||
| collect: z.number().int().positive().max(64).optional() | ||
| .describe('Collect up to N distinct crashes (default 8).'), | ||
| seed_inputs_hex: z.array(z.string()).optional() | ||
| .describe('Seed inputs as hex byte strings, e.g. ["5000"]. Optional.'), | ||
| contract: z | ||
| .object({ | ||
| input_len_addr: z.string().optional(), | ||
| input_data_addr: z.string().optional(), | ||
| verdict_addr: z.string().optional(), | ||
| done_magic: z.string().optional(), | ||
| fault_magic: z.string().optional(), | ||
| }) | ||
| .optional() | ||
| .describe('Override the fuzz contract addresses/markers (hex). Defaults match the F103 fuzz target.'), | ||
| }); | ||
| const InspectRunInput = z.object({ | ||
| snapshot_id: z.string().describe('snapshot_id returned by labwired_run.'), | ||
| scope: z | ||
| .enum(['summary', 'serial', 'gpio', 'raw']) | ||
| .default('summary') | ||
| .describe('summary | serial | gpio | raw. Default summary.'), | ||
| }); | ||
| const DefineComponentInput = z.object({ | ||
| spec_yaml: z.string().describe('IrComponent spec as YAML'), | ||
| name: z.string().optional().describe('Override file name (defaults to spec name, kebab-cased)'), | ||
| }); | ||
| const IngestSvdInput = z.object({ | ||
| svd_content: z.string().describe('Full contents of the CMSIS-SVD XML file to ingest.'), | ||
| filter: z | ||
| .string() | ||
| .optional() | ||
| .describe('Comma-separated peripheral names to ingest (default: all peripherals).'), | ||
| }); | ||
| const ValidateDiagramInput = z.object({ | ||
| diagram: z | ||
| .object({ | ||
| board: z.string(), | ||
| parts: z.array(z.object({ id: z.string(), type: z.string() }).passthrough()), | ||
| // wires is optional: v2 diagrams may use only connections/nets | ||
| wires: z.array(z.object({ | ||
| from: z.object({ part: z.string(), pin: z.string() }), | ||
| to: z.object({ part: z.string(), pin: z.string() }), | ||
| })).optional(), | ||
| }) | ||
| .passthrough() | ||
| .describe('Diagram JSON: { board, parts: [{id, type, ...}], wires?: [{from, to}] }.'), | ||
| }); | ||
| // ─── Workspace root (mirrors boards.ts resolution logic) ────────────────── | ||
| // Checked in order: LABWIRED_REPO_ROOT env, then walk up from __dirname. | ||
| import { existsSync } from 'node:fs'; | ||
| import { dirname } from 'node:path'; | ||
| import { fileURLToPath } from 'node:url'; | ||
| function resolveWorkspaceRoot() { | ||
| const fromEnv = process.env.LABWIRED_REPO_ROOT; | ||
| if (fromEnv) | ||
| return resolve(fromEnv); | ||
| let cursor = dirname(fileURLToPath(import.meta.url)); | ||
| for (let i = 0; i < 8; i++) { | ||
| if (existsSync(join(cursor, 'core/configs/chips'))) | ||
| return cursor; | ||
| const parent = dirname(cursor); | ||
| if (parent === cursor) | ||
| break; | ||
| cursor = parent; | ||
| } | ||
| // Fall back to cwd when repo root cannot be detected | ||
| return process.cwd(); | ||
| } | ||
| /** Convert any string to a safe kebab-case file stem. */ | ||
| function toKebabCase(s) { | ||
| return s | ||
| .toLowerCase() | ||
| .replace(/[^a-z0-9]+/g, '-') | ||
| .replace(/^-+|-+$/g, ''); | ||
| } | ||
| const server = new Server({ name: SERVER_NAME, version: SERVER_VERSION }, { capabilities: { tools: {}, resources: {} } }); | ||
| function localTools() { | ||
| return [ | ||
| SEARCH_TOOLS_TOOL, | ||
| { | ||
@@ -58,28 +232,120 @@ name: 'labwired_catalog', | ||
| { | ||
| name: 'labwired_simulate', | ||
| description: 'Run firmware against the LabWired deterministic simulator. ' + | ||
| 'Identical inputs always produce identical outputs (cycle-accurate). ' + | ||
| 'Returns result.json (assertions, exit status, cycles consumed) and the captured UART log. ' + | ||
| 'Use this to verify firmware behaviour, reproduce bugs, or check whether a fix works.', | ||
| name: 'labwired_run_device', | ||
| description: 'Boot an Arduino-ESP32 firmware ELF on simulated ESP32 hardware and report what the attached ' + | ||
| 'device actually did. The peripherals (e.g. an SSD1680/UC8151D e-paper panel and its CS/DC ' + | ||
| 'pins) are attached from the system_yaml manifest. Works for ANY symbol-bearing Arduino-ESP32 ' + | ||
| 'build (agent-compiled firmware always has symbols). Use this for real firmware driving a ' + | ||
| 'display — labwired_verify runs the bare test path which does NOT boot Arduino-ESP32 and will ' + | ||
| 'fault. Returns whether the panel painted, its refresh generation, and how much ink reached it ' + | ||
| '(the deterministic hardware oracle, byte-exact to silicon).', | ||
| inputSchema: { | ||
| type: 'object', | ||
| required: ['firmware_base64', 'system_yaml', 'script_yaml'], | ||
| required: ['firmware_base64', 'system_yaml'], | ||
| properties: { | ||
| firmware_base64: { | ||
| firmware_base64: { type: 'string', description: 'Base64-encoded Arduino-ESP32 (Xtensa) ELF.' }, | ||
| system_yaml: { type: 'string', description: 'System Manifest YAML — chip + connected peripherals (e.g. an e-paper panel on spi3 with CS/DC pins).' }, | ||
| steps: { type: 'integer', description: 'Cycles to run (default 45,000,000; max 50,000,000).' }, | ||
| }, | ||
| }, | ||
| }, | ||
| { | ||
| name: 'labwired_validate_system', | ||
| description: 'Validate a System Manifest YAML against the LabWired schema and confirm referenced ' + | ||
| 'chip descriptors exist. Returns the validator stdout / stderr. ' + | ||
| 'Use this before simulate() to catch schema errors fast.', | ||
| inputSchema: { | ||
| type: 'object', | ||
| required: ['system_yaml'], | ||
| properties: { | ||
| system_yaml: { | ||
| type: 'string', | ||
| description: 'Base64-encoded ELF firmware image to load into the simulator.', | ||
| description: 'Full contents of the System Manifest YAML to validate.', | ||
| }, | ||
| system_yaml: { | ||
| }, | ||
| }, | ||
| }, | ||
| { | ||
| name: 'labwired_list_boards', | ||
| description: 'List supported boards (chip + pre-wired peripherals + demo firmware). Higher-level ' + | ||
| 'than labwired_catalog: each board has a complete simulation config you can run with ' + | ||
| 'labwired_run. Returns id, name, chip family, arch, and description for each.', | ||
| inputSchema: { | ||
| type: 'object', | ||
| properties: { | ||
| filter: { | ||
| type: 'string', | ||
| description: 'System Manifest YAML defining chip, peripherals, memory map. ' + | ||
| 'See github.com/w1ne/labwired for schema.', | ||
| description: 'Substring filter on id / name / chip family.', | ||
| }, | ||
| script_yaml: { | ||
| }, | ||
| }, | ||
| }, | ||
| { | ||
| name: 'labwired_describe_board', | ||
| description: 'Describe a board/chip: its canonical id, chip family, runnable flag, toolchains, the full ' + | ||
| 'pin table, per-bus (I2C/SPI/UART) default pins, and a starter LED pin. Use this FIRST for ' + | ||
| 'discovery — it kills target-name guessing and tells you which GPIO to wire each bus to ' + | ||
| 'before building a diagram or firmware. Accepts any spelling (canonical id, playground id, ' + | ||
| 'or bare chip key); resolution is normalized. Free; no run required.', | ||
| inputSchema: { | ||
| type: 'object', | ||
| required: ['board'], | ||
| properties: { | ||
| board: { | ||
| type: 'string', | ||
| description: 'Test script YAML: max_cycles, breakpoints, assertions on UART / registers / memory.', | ||
| description: 'Any accepted board/chip spelling, e.g. "esp32c3-supermini" or "esp32c3".', | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| { | ||
| name: 'labwired_run', | ||
| description: 'Run a firmware ELF against a pre-configured board and OBSERVE what it did (serial, exit ' + | ||
| 'reason, cycles). THE canonical execution tool. Pick a board with labwired_list_boards, ' + | ||
| "compile locally (we don't compile for you), then upload the ELF. Observational: it reports " + | ||
| 'behaviour but never mints a "verified" verdict — use labwired_verify with an oracle for a ' + | ||
| 'gated pass. Returns a snapshot_id for labwired_inspect_run. Deterministic. `output` selects ' + | ||
| 'the response shape; `stimuli` drives a sensor mid-run.', | ||
| inputSchema: { | ||
| type: 'object', | ||
| properties: { | ||
| target: { | ||
| type: 'string', | ||
| description: 'Board id / target from labwired_list_boards (e.g. "stm32f103-blinky").', | ||
| }, | ||
| board_id: { | ||
| type: 'string', | ||
| description: 'Deprecated alias for `target`.', | ||
| }, | ||
| elf_base64: { | ||
| type: 'string', | ||
| description: 'Base64-encoded ELF binary. Agent compiles locally. Optional if firmware_ref is given.', | ||
| }, | ||
| firmware_ref: { | ||
| type: 'string', | ||
| description: 'Content-addressed firmware ref (`sha256:<hex>` from labwired_compile_firmware); resolved ' + | ||
| 'from the hosted blob store, so there is no base64 to carry. Used when elf_base64 is omitted.', | ||
| }, | ||
| max_cycles: { | ||
| type: 'integer', | ||
| description: 'Optional override for max cycles.', | ||
| description: 'Cycle budget (default 10M, hard cap 100M).', | ||
| }, | ||
| output: { | ||
| type: 'string', | ||
| enum: ['summary', 'serial', 'peripherals', 'full'], | ||
| description: 'Response-shape selector. Default full.', | ||
| }, | ||
| stimuli: { | ||
| type: 'array', | ||
| description: 'Declarative input stimuli — drive a sensor mid-run, then keep going.', | ||
| items: { | ||
| type: 'object', | ||
| required: ['channel', 'value'], | ||
| properties: { | ||
| channel: { type: 'string' }, | ||
| value: { type: 'number' }, | ||
| after_cycles: { type: 'integer' }, | ||
| component: { type: 'string' }, | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
@@ -89,22 +355,246 @@ }, | ||
| { | ||
| name: 'labwired_validate_system', | ||
| description: 'Validate a System Manifest YAML against the LabWired schema and confirm referenced ' + | ||
| 'chip descriptors exist. Returns the validator stdout / stderr. ' + | ||
| 'Use this before simulate() to catch schema errors fast.', | ||
| name: 'labwired_fuzz', | ||
| description: 'Coverage-guided fuzz a firmware ELF in the silicon-validated simulator and return the ' + | ||
| 'crashing inputs. AFL-style edge coverage drives mutation; a crash is a CPU fault or the ' + | ||
| "firmware's FAULT marker. Because the sim is silicon-validated, crashes found here are " + | ||
| 'replayable on real hardware (HIL-confirm) — silicon-true findings, not emulation false ' + | ||
| 'positives. The target firmware must follow the fuzz contract (RAM length+data input ' + | ||
| 'buffer, a verdict word with DONE/FAULT markers). Deterministic for a fixed seed. Returns ' + | ||
| 'the distinct crashing inputs (as byte arrays) you can replay or minimize.', | ||
| inputSchema: { | ||
| type: 'object', | ||
| required: ['board_id', 'elf_base64'], | ||
| properties: { | ||
| board_id: { | ||
| type: 'string', | ||
| description: 'Board id from labwired_list_boards (resolves chip + system YAML).', | ||
| }, | ||
| elf_base64: { | ||
| type: 'string', | ||
| description: 'Base64-encoded fuzz-target ELF following the fuzz contract. Agent compiles locally.', | ||
| }, | ||
| max_iters: { | ||
| type: 'integer', | ||
| description: 'Max fuzzing iterations (default 200000).', | ||
| }, | ||
| seed: { | ||
| type: 'integer', | ||
| description: 'RNG seed — fuzzing is deterministic for a fixed seed.', | ||
| }, | ||
| collect: { | ||
| type: 'integer', | ||
| description: 'Collect up to N distinct crashes (default 8).', | ||
| }, | ||
| seed_inputs_hex: { | ||
| type: 'array', | ||
| items: { type: 'string' }, | ||
| description: 'Seed inputs as hex byte strings, e.g. ["5000"].', | ||
| }, | ||
| contract: { | ||
| type: 'object', | ||
| description: 'Override the fuzz contract (hex strings). Defaults match the F103 fuzz target: ' + | ||
| 'input_len_addr 0x20002800, input_data_addr 0x20002804, verdict_addr 0x20003000, ' + | ||
| 'done_magic 0xC0DEF022, fault_magic 0xDEADFA17.', | ||
| properties: { | ||
| input_len_addr: { type: 'string' }, | ||
| input_data_addr: { type: 'string' }, | ||
| verdict_addr: { type: 'string' }, | ||
| done_magic: { type: 'string' }, | ||
| fault_magic: { type: 'string' }, | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| { | ||
| name: 'labwired_inspect_run', | ||
| description: 'Retrieve a deeper slice of state from a prior labwired_run snapshot. Use scope=summary ' + | ||
| 'for cycles + PC + exit reason, scope=serial for full UART transcript, scope=gpio for pin ' + | ||
| 'transitions (when CLI emits them), scope=raw for the underlying result.json. Snapshots ' + | ||
| 'expire after 10 min.', | ||
| inputSchema: { | ||
| type: 'object', | ||
| required: ['snapshot_id'], | ||
| properties: { | ||
| snapshot_id: { | ||
| type: 'string', | ||
| description: 'snapshot_id from labwired_run output.', | ||
| }, | ||
| scope: { | ||
| type: 'string', | ||
| enum: ['summary', 'serial', 'gpio', 'raw'], | ||
| description: 'Slice to return. Default summary.', | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| { | ||
| name: 'labwired_validate_diagram', | ||
| description: 'Structurally validate a wired diagram BEFORE attempting to run it. Returns an array of ' + | ||
| 'machine-readable diagnostics (severity, code, message, location, suggested fix) — much ' + | ||
| 'friendlier than waiting for the simulator to fail at run time. Common codes: ' + | ||
| 'PIN_NOT_ON_CHIP, PIN_LACKS_I2C, BOARDIO_NOT_TO_MCU, NO_MCU, COMPONENT_DANGLING. Use this ' + | ||
| 'when building circuits programmatically; iterate until you get back an empty array.', | ||
| inputSchema: { | ||
| type: 'object', | ||
| required: ['diagram'], | ||
| properties: { | ||
| diagram: { | ||
| type: 'object', | ||
| description: 'Diagram JSON: { board: "stm32f103", parts: [{id, type}], wires: [{from:{part,pin}, to:{part,pin}}] }', | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| { | ||
| name: 'labwired_define_component', | ||
| description: 'Define a new off-chip device from a declarative IR spec (YAML). Validates the spec ' + | ||
| '(stable ICOMP_* diagnostic codes with hints), persists it under ' + | ||
| '.labwired/components/<name>.yaml, and returns the exact manifest external_devices ' + | ||
| 'entry (type: ir, spec_path) needed to wire the device into a system manifest. ' + | ||
| 'Use this to model any custom sensor, driver, or expander before simulation. ' + | ||
| 'Keywords: define component, ir spec, custom device, sensor model.', | ||
| inputSchema: { | ||
| type: 'object', | ||
| required: ['spec_yaml'], | ||
| properties: { | ||
| spec_yaml: { | ||
| type: 'string', | ||
| description: 'IrComponent spec as YAML', | ||
| }, | ||
| name: { | ||
| type: 'string', | ||
| description: 'Override file name (defaults to spec name, kebab-cased)', | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| { | ||
| name: 'labwired_compile_diagram', | ||
| description: 'Compile a wired diagram into a runnable LabWired System Manifest YAML. ' + | ||
| 'Runs electrical rule checks (ERC) first — any error aborts compilation. ' + | ||
| 'On success, persists the manifest to <repoRoot>/.labwired/boards/<name>.yaml ' + | ||
| 'and returns the board_path, system_yaml, and any warnings. ' + | ||
| 'Use this after labwired_validate_diagram to produce the manifest for labwired_verify. ' + | ||
| 'Keywords: compile diagram, diagram to manifest, build board.', | ||
| inputSchema: { | ||
| type: 'object', | ||
| required: ['diagram'], | ||
| properties: { | ||
| diagram: { | ||
| type: 'object', | ||
| description: 'Diagram JSON: { board, parts: [{id, type, ...}], wires: [{from, to}] }', | ||
| }, | ||
| name: { | ||
| type: 'string', | ||
| description: 'Optional name for the output file (defaults to board name). Kebab-cased.', | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| { | ||
| name: 'labwired_verify', | ||
| description: 'THE mandatory-oracle gate: run YOUR OWN build server-side and PROVE it worked. You SUPPLY ' + | ||
| 'the compiled firmware ELF (base64) + the LabWired system manifest (YAML), plus a passed ' + | ||
| 'behavioral proof — EITHER a raw `test_yaml` (schema "1.0" assertions, the reference power ' + | ||
| 'path, evaluated by `labwired test` inside the container) OR an `oracle` block with at least ' + | ||
| 'one clause (serial / display / registers / gpio), which is evaluated by the shared ' + | ||
| 'evaluateOracle. Deterministic oracle run on the modeled hardware, not a self-reported claim. ' + | ||
| 'A missing oracle AND missing test_yaml → ORACLE_REQUIRED (refuses to verify). ' + | ||
| 'On the stdio path only `serial` (UART) and `display` clauses are observable; register/gpio ' + | ||
| 'clauses need the hosted /run peripherals path and are flagged unevaluable. ' + | ||
| 'Returns { ok, proven, verification, status, oracle_results[], assertions[], uart_excerpt }.', | ||
| inputSchema: { | ||
| type: 'object', | ||
| required: ['system_yaml'], | ||
| properties: { | ||
| firmware_base64: { | ||
| type: 'string', | ||
| description: 'Base64-encoded compiled firmware ELF for your build. Optional if firmware_ref is given.', | ||
| }, | ||
| firmware_ref: { | ||
| type: 'string', | ||
| description: 'Content-addressed firmware ref (`sha256:<hex>` from labwired_compile_firmware); resolved ' + | ||
| 'from the hosted blob store, so there is no base64 to carry. Used when firmware_base64 is omitted.', | ||
| }, | ||
| system_yaml: { | ||
| type: 'string', | ||
| description: 'Full contents of the System Manifest YAML to validate.', | ||
| description: 'LabWired system manifest (YAML) describing the modeled hardware your firmware runs on.', | ||
| }, | ||
| test_yaml: { | ||
| type: 'string', | ||
| description: 'Optional reference power path: LabWired test script (YAML, schema_version "1.0") with limits + assertions. ' + | ||
| 'Its inputs.firmware / inputs.system are overridden by the supplied firmware + manifest.', | ||
| }, | ||
| oracle: { | ||
| type: 'object', | ||
| description: 'Behavioral proof (at least one clause). { serial: [{contains|matches}], ' + | ||
| 'display: {painted, min_ink_bytes, min_refresh_generation, panel}, ' + | ||
| 'registers: [{peripheral, register, field?, equals, mask?}], gpio: [{pin, state}] }.', | ||
| properties: { | ||
| serial: { type: 'array', items: { type: 'object' } }, | ||
| display: { type: 'object' }, | ||
| registers: { type: 'array', items: { type: 'object' } }, | ||
| gpio: { type: 'array', items: { type: 'object' } }, | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| ], | ||
| { | ||
| name: 'labwired_ingest_svd', | ||
| description: 'Ingest a CMSIS-SVD file into runnable declarative PeripheralDescriptor YAML — the ' + | ||
| 'one-step path from a silicon-vendor SVD to a working chip, with no codegen and no ' + | ||
| 'recompile. Writes descriptors under .labwired/peripherals/<name>.yaml and returns each ' + | ||
| 'peripheral with its descriptor YAML, base address, and register count, plus a ' + | ||
| 'paste-ready chip-yaml peripherals block (type: declarative) to drop straight into a ' + | ||
| 'chip descriptor. Use this to model any MCU peripheral from its vendor SVD. ' + | ||
| 'Keywords: svd import, declarative peripheral, chip from svd, register map.', | ||
| inputSchema: { | ||
| type: 'object', | ||
| required: ['svd_content'], | ||
| properties: { | ||
| svd_content: { | ||
| type: 'string', | ||
| description: 'Full CMSIS-SVD XML contents.', | ||
| }, | ||
| filter: { | ||
| type: 'string', | ||
| description: 'Comma-separated peripheral names to ingest (default: all).', | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| ]; | ||
| } | ||
| server.setRequestHandler(ListToolsRequestSchema, async () => ({ | ||
| tools: decorateTools(localTools()), | ||
| })); | ||
| server.setRequestHandler(ListResourcesRequestSchema, async () => ({ | ||
| resources: RESOURCES, | ||
| })); | ||
| server.setRequestHandler(ReadResourceRequestSchema, async (request) => { | ||
| const resource = getResource(request.params.uri); | ||
| if (!resource) | ||
| throw new Error(`Unknown resource: ${request.params.uri}`); | ||
| return { contents: [resource] }; | ||
| }); | ||
| server.setRequestHandler(CallToolRequestSchema, async (request) => { | ||
| const { name, arguments: args } = request.params; | ||
| try { | ||
| if (name === SEARCH_TOOLS_TOOL_NAME) { | ||
| const input = (args ?? {}); | ||
| const query = typeof input.query === 'string' ? input.query : ''; | ||
| const limit = typeof input.limit === 'number' && Number.isFinite(input.limit) | ||
| ? Math.trunc(input.limit) | ||
| : 8; | ||
| return { | ||
| content: [{ type: 'text', text: JSON.stringify({ | ||
| query, | ||
| guide_uri: AGENT_HARDWARE_LOOP_GUIDE_URI, | ||
| workflow: LOCAL_AGENT_WORKFLOW, | ||
| tools: rankTools(query, decorateTools(localTools()), limit), | ||
| }) }], | ||
| }; | ||
| } | ||
| if (name === 'labwired_catalog') { | ||
@@ -117,15 +607,290 @@ const { filter } = CatalogInput.parse(args ?? {}); | ||
| } | ||
| if (name === 'labwired_simulate') { | ||
| const input = SimulateInput.parse(args ?? {}); | ||
| const run = await runSimulation({ | ||
| firmwareBase64: input.firmware_base64, | ||
| systemYaml: input.system_yaml, | ||
| scriptYaml: input.script_yaml, | ||
| if (name === 'labwired_run_device') { | ||
| const a = (args ?? {}); | ||
| if (!a.firmware_base64 || !a.system_yaml) { | ||
| return { content: [{ type: 'text', text: JSON.stringify({ error: 'firmware_base64 and system_yaml are required' }) }], isError: true }; | ||
| } | ||
| const run = await runDeviceCapture({ firmwareBase64: a.firmware_base64, systemYaml: a.system_yaml, steps: a.steps }); | ||
| return { | ||
| content: [{ type: 'text', text: JSON.stringify(run, null, 2) }], | ||
| isError: !run.painted, | ||
| }; | ||
| } | ||
| if (name === 'labwired_validate_system') { | ||
| const { system_yaml } = ValidateInput.parse(args ?? {}); | ||
| const result = await validateSystem(system_yaml); | ||
| return { | ||
| content: [ | ||
| { | ||
| type: 'text', | ||
| text: JSON.stringify({ exit_code: result.exitCode, stdout: result.stdout, stderr: result.stderr }, null, 2), | ||
| }, | ||
| ], | ||
| isError: result.exitCode !== 0, | ||
| }; | ||
| } | ||
| if (name === 'labwired_list_boards') { | ||
| const { filter } = ListBoardsInput.parse(args ?? {}); | ||
| const boards = listBoards(filter); | ||
| return { | ||
| content: [{ type: 'text', text: JSON.stringify({ boards }, null, 2) }], | ||
| }; | ||
| } | ||
| if (name === 'labwired_describe_board') { | ||
| const { board } = DescribeBoardInput.parse(args ?? {}); | ||
| const descriptor = describeBoard(board); | ||
| if (!descriptor) { | ||
| return { | ||
| content: [ | ||
| { | ||
| type: 'text', | ||
| text: JSON.stringify({ | ||
| error: 'UNKNOWN_BOARD', | ||
| message: `No board/chip matches "${board}". Call labwired_list_boards for valid ids.`, | ||
| }, null, 2), | ||
| }, | ||
| ], | ||
| isError: true, | ||
| }; | ||
| } | ||
| return { content: [{ type: 'text', text: JSON.stringify(descriptor, null, 2) }] }; | ||
| } | ||
| if (name === 'labwired_run') { | ||
| const input = RunLabInput.parse(args ?? {}); | ||
| const boardId = input.target ?? input.board_id; | ||
| const output = input.output ?? 'full'; | ||
| const board = getBoard(boardId); | ||
| if (!board) { | ||
| return { | ||
| content: [ | ||
| { | ||
| type: 'text', | ||
| text: JSON.stringify({ | ||
| error: 'INVALID_BOARD', | ||
| message: `Unknown target "${boardId}". Call labwired_list_boards to see available ids.`, | ||
| }, null, 2), | ||
| }, | ||
| ], | ||
| isError: true, | ||
| }; | ||
| } | ||
| let yamls; | ||
| try { | ||
| yamls = await readBoardYamls(board); | ||
| } | ||
| catch (e) { | ||
| return { | ||
| content: [ | ||
| { | ||
| type: 'text', | ||
| text: JSON.stringify({ | ||
| error: 'BOARD_YAMLS_UNAVAILABLE', | ||
| message: e instanceof Error ? e.message : String(e), | ||
| hint: 'The MCP server needs the labwired repo on disk to resolve board YAMLs. Set LABWIRED_REPO_ROOT if running outside the repo.', | ||
| }, null, 2), | ||
| }, | ||
| ], | ||
| isError: true, | ||
| }; | ||
| } | ||
| // Ref-first firmware currency: inline elf_base64 wins; otherwise resolve a | ||
| // content-addressed `sha256:<hex>` ref (top-level firmware_ref or a lab | ||
| // document's diagram.firmware.artifact.ref) from the hosted blob store. | ||
| let elfBase64 = input.elf_base64; | ||
| if (!elfBase64) { | ||
| const ref = firmwareRefFromArgs(args); | ||
| if (!ref) { | ||
| return { | ||
| content: [ | ||
| { | ||
| type: 'text', | ||
| text: JSON.stringify({ | ||
| error: 'FIRMWARE_REQUIRED', | ||
| message: 'Provide elf_base64 or firmware_ref (sha256:<hex> from labwired_compile_firmware).', | ||
| }, null, 2), | ||
| }, | ||
| ], | ||
| isError: true, | ||
| }; | ||
| } | ||
| try { | ||
| elfBase64 = await resolveFirmwareRef(ref); | ||
| } | ||
| catch (e) { | ||
| return { | ||
| content: [ | ||
| { | ||
| type: 'text', | ||
| text: JSON.stringify({ error: 'FIRMWARE_REF_UNRESOLVED', message: e instanceof Error ? e.message : String(e) }, null, 2), | ||
| }, | ||
| ], | ||
| isError: true, | ||
| }; | ||
| } | ||
| } | ||
| const firmware = Buffer.from(elfBase64, 'base64'); | ||
| if (firmware.length < 4 || firmware.subarray(0, 4).toString('hex') !== '7f454c46') { | ||
| return { | ||
| content: [ | ||
| { | ||
| type: 'text', | ||
| text: JSON.stringify({ | ||
| error: 'INVALID_ELF', | ||
| message: 'Decoded firmware is not an ELF file (missing 0x7F454C46 magic).', | ||
| }, null, 2), | ||
| }, | ||
| ], | ||
| isError: true, | ||
| }; | ||
| } | ||
| // Use absolute repo path for systemYaml so relative refs | ||
| // (chip:, descriptor:) inside it resolve to core/configs/... | ||
| const run = await runLab({ | ||
| systemYamlPath: boardSystemYamlPath(board), | ||
| firmware, | ||
| maxCycles: input.max_cycles, | ||
| stimuli: input.stimuli?.map((s) => ({ | ||
| channel: s.channel, | ||
| value: s.value, | ||
| afterCycles: s.after_cycles, | ||
| component: s.component, | ||
| })), | ||
| }); | ||
| const snapshot_id = putSnapshot({ | ||
| registers: undefined, | ||
| serial_output: run.serial_output, | ||
| gpio_events: run.gpio_events, | ||
| gpio_truncated: run.gpio_truncated, | ||
| final_pc_hex: run.final_pc_hex, | ||
| final_cycles: run.final_cycles, | ||
| raw_result: run.raw_result, | ||
| board_id: boardId, | ||
| created_at: Date.now(), | ||
| }); | ||
| // Trim serial in the inline response — full transcript is in snapshot. | ||
| const SERIAL_INLINE_CAP = 8000; | ||
| const serialInline = run.serial_output.slice(-SERIAL_INLINE_CAP); | ||
| // Surface first-class unmodeled-access faults when the run path exposes | ||
| // them (fail-loud, never thunk). See scanUnmodeledAccess: the stdio CLI | ||
| // path does NOT emit a structured event, so this is a best-effort text | ||
| // scan and its absence is a core/CLI gap, not a clean bill of health. | ||
| const unmodeled_access = scanUnmodeledAccess(run.stderr, typeof run.raw_result === 'string' ? run.raw_result : JSON.stringify(run.raw_result ?? '')); | ||
| const full = { | ||
| ok: run.exit_code === 0, | ||
| snapshot_id, | ||
| target: boardId, | ||
| board_id: boardId, | ||
| exit_reason: run.exit_reason, | ||
| final_cycles: run.final_cycles, | ||
| final_pc_hex: run.final_pc_hex, | ||
| serial_tail: serialInline, | ||
| serial_truncated_in_response: run.serial_output.length > SERIAL_INLINE_CAP || run.serial_truncated, | ||
| stderr_excerpt: run.stderr.slice(0, 2000), | ||
| ...(unmodeled_access.length ? { unmodeled_access } : {}), | ||
| }; | ||
| // `output` selects the response shape. peripherals are not emitted by the | ||
| // stdio local run path — inspect_run reads gpio/serial from the snapshot. | ||
| let payload; | ||
| if (output === 'summary') { | ||
| const { serial_tail: _s, ...rest } = full; | ||
| payload = rest; | ||
| } | ||
| else if (output === 'serial') { | ||
| payload = { ok: full.ok, snapshot_id, target: boardId, serial_tail: serialInline, serial_truncated_in_response: full.serial_truncated_in_response }; | ||
| } | ||
| else if (output === 'peripherals') { | ||
| payload = { | ||
| ok: full.ok, | ||
| snapshot_id, | ||
| target: boardId, | ||
| note: 'The stdio local run path does not emit a peripheral inspect block. ' + | ||
| 'Use labwired_inspect_run (scope=gpio|serial) on the snapshot_id for state, ' + | ||
| 'or the hosted /run peripherals path for a full inspect.', | ||
| ...(unmodeled_access.length ? { unmodeled_access } : {}), | ||
| }; | ||
| } | ||
| else { | ||
| payload = full; | ||
| } | ||
| return { | ||
| content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], | ||
| isError: run.exit_code !== 0, | ||
| }; | ||
| } | ||
| if (name === 'labwired_fuzz') { | ||
| const input = FuzzInput.parse(args ?? {}); | ||
| const board = getBoard(input.board_id); | ||
| if (!board) { | ||
| return { | ||
| content: [ | ||
| { | ||
| type: 'text', | ||
| text: JSON.stringify({ | ||
| error: 'INVALID_BOARD', | ||
| message: `Unknown board_id "${input.board_id}". Call labwired_list_boards to see available ids.`, | ||
| }, null, 2), | ||
| }, | ||
| ], | ||
| isError: true, | ||
| }; | ||
| } | ||
| const firmware = Buffer.from(input.elf_base64, 'base64'); | ||
| if (firmware.length < 4 || firmware.subarray(0, 4).toString('hex') !== '7f454c46') { | ||
| return { | ||
| content: [ | ||
| { | ||
| type: 'text', | ||
| text: JSON.stringify({ | ||
| error: 'INVALID_ELF', | ||
| message: 'Decoded firmware is not an ELF file (missing 0x7F454C46 magic).', | ||
| }, null, 2), | ||
| }, | ||
| ], | ||
| isError: true, | ||
| }; | ||
| } | ||
| let run; | ||
| try { | ||
| run = await fuzzFirmware({ | ||
| chipYamlPath: boardChipYamlPath(board), | ||
| systemYamlPath: boardSystemYamlPath(board), | ||
| firmware, | ||
| maxIters: input.max_iters, | ||
| seed: input.seed, | ||
| collect: input.collect, | ||
| seedInputsHex: input.seed_inputs_hex, | ||
| contract: input.contract && { | ||
| inputLenAddr: input.contract.input_len_addr, | ||
| inputDataAddr: input.contract.input_data_addr, | ||
| verdictAddr: input.contract.verdict_addr, | ||
| doneMagic: input.contract.done_magic, | ||
| faultMagic: input.contract.fault_magic, | ||
| }, | ||
| }); | ||
| } | ||
| catch (e) { | ||
| return { | ||
| content: [ | ||
| { | ||
| type: 'text', | ||
| text: JSON.stringify({ | ||
| error: 'FUZZ_FAILED', | ||
| message: e instanceof Error ? e.message : String(e), | ||
| hint: 'The MCP server needs the labwired repo on disk to resolve board YAMLs. Set LABWIRED_REPO_ROOT.', | ||
| }, null, 2), | ||
| }, | ||
| ], | ||
| isError: true, | ||
| }; | ||
| } | ||
| const summary = { | ||
| exit_code: run.exitCode, | ||
| result: run.resultJson, | ||
| uart_log_excerpt: run.uartLog.slice(0, 4000), | ||
| uart_log_truncated: run.uartLog.length > 4000, | ||
| crashed: run.crashed, | ||
| crash_count: run.crashes.length, | ||
| // Crashes as hex strings for readability + raw byte arrays for replay. | ||
| crashes_hex: run.crashes.map((c) => c.map((b) => b.toString(16).padStart(2, '0')).join('')), | ||
| crashes: run.crashes, | ||
| note: run.crashed | ||
| ? 'Crashes are silicon-true: replay any input on the F103 via the HIL-confirm harness to confirm.' | ||
| : 'No crash found within the iteration budget.', | ||
| stdout_excerpt: run.stdout.slice(0, 2000), | ||
| stderr_excerpt: run.stderr.slice(0, 2000), | ||
@@ -135,8 +900,10 @@ }; | ||
| content: [{ type: 'text', text: JSON.stringify(summary, null, 2) }], | ||
| isError: run.exitCode !== 0, | ||
| // A found crash is a finding, not a tool error — surface it as content, | ||
| // not isError (which signals the tool itself failed). | ||
| isError: false, | ||
| }; | ||
| } | ||
| if (name === 'labwired_validate_system') { | ||
| const { system_yaml } = ValidateInput.parse(args ?? {}); | ||
| const result = await validateSystem(system_yaml); | ||
| if (name === 'labwired_validate_diagram') { | ||
| const { diagram } = ValidateDiagramInput.parse(args ?? {}); | ||
| const result = composeDiagnostics(diagram); | ||
| return { | ||
@@ -146,8 +913,391 @@ content: [ | ||
| type: 'text', | ||
| text: JSON.stringify({ exit_code: result.exitCode, stdout: result.stdout, stderr: result.stderr }, null, 2), | ||
| text: JSON.stringify(result, null, 2), | ||
| }, | ||
| ], | ||
| isError: result.exitCode !== 0, | ||
| isError: result.error_count > 0, | ||
| }; | ||
| } | ||
| if (name === 'labwired_compile_diagram') { | ||
| const input = (args ?? {}); | ||
| const diagram = input.diagram; | ||
| const nameOverride = typeof input.name === 'string' ? input.name : undefined; | ||
| if (!diagram || typeof diagram !== 'object') { | ||
| return { | ||
| content: [{ type: 'text', text: JSON.stringify({ error: 'INVALID_ARGS', detail: 'diagram is required' }, null, 2) }], | ||
| isError: true, | ||
| }; | ||
| } | ||
| const result = compile(diagram); | ||
| if (!result.ok) { | ||
| return { | ||
| content: [{ type: 'text', text: JSON.stringify({ ok: false, diagnostics: result.diagnostics }, null, 2) }], | ||
| isError: true, | ||
| }; | ||
| } | ||
| // Persist to .labwired/boards/<name>.yaml | ||
| const diagramAny = diagram; | ||
| const boardName = typeof diagramAny.board === 'string' ? diagramAny.board : 'board'; | ||
| const stem = nameOverride ? toKebabCase(nameOverride) : toKebabCase(boardName); | ||
| const fileName = `${stem}.yaml`; | ||
| const workspaceRoot = resolveWorkspaceRoot(); | ||
| const boardsDir = join(workspaceRoot, '.labwired', 'boards'); | ||
| await mkdir(boardsDir, { recursive: true }); | ||
| const boardPath = join(boardsDir, fileName); | ||
| await writeFile(boardPath, result.systemYaml ?? ''); | ||
| const absBoardPath = resolve(boardPath); | ||
| return { | ||
| content: [ | ||
| { | ||
| type: 'text', | ||
| text: JSON.stringify({ | ||
| ok: true, | ||
| board_path: absBoardPath, | ||
| system_yaml: result.systemYaml, | ||
| diagnostics: result.diagnostics, | ||
| }, null, 2), | ||
| }, | ||
| ], | ||
| }; | ||
| } | ||
| if (name === 'labwired_define_component') { | ||
| const { spec_yaml, name: nameOverride } = DefineComponentInput.parse(args ?? {}); | ||
| // Write spec to a temp file for CLI validation | ||
| const work = await mkdtemp(join(tmpdir(), 'labwired-mcp-comp-')); | ||
| let report; | ||
| try { | ||
| const tmpSpec = join(work, 'spec.yaml'); | ||
| await writeFile(tmpSpec, spec_yaml); | ||
| const { stdout, exitCode } = await runCli(['asset', 'validate-component', tmpSpec, '--json']); | ||
| try { | ||
| report = JSON.parse(stdout); | ||
| } | ||
| catch { | ||
| return { | ||
| content: [{ type: 'text', text: JSON.stringify({ error: 'CLI_PARSE_ERROR', stdout, exit_code: exitCode }, null, 2) }], | ||
| isError: true, | ||
| }; | ||
| } | ||
| } | ||
| finally { | ||
| await rm(work, { recursive: true, force: true }).catch(() => { }); | ||
| } | ||
| if (!report.ok) { | ||
| return { | ||
| content: [{ type: 'text', text: JSON.stringify(report, null, 2) }], | ||
| isError: true, | ||
| }; | ||
| } | ||
| // Compute file name: explicit override, else spec name kebab-cased | ||
| const stem = nameOverride | ||
| ? toKebabCase(nameOverride) | ||
| : toKebabCase(report.name ?? 'component'); | ||
| const fileName = `${stem}.yaml`; | ||
| const workspaceRoot = resolveWorkspaceRoot(); | ||
| const componentDir = join(workspaceRoot, '.labwired', 'components'); | ||
| await mkdir(componentDir, { recursive: true }); | ||
| const specPath = join(componentDir, fileName); | ||
| await writeFile(specPath, spec_yaml); | ||
| const absSpecPath = resolve(specPath); | ||
| return { | ||
| content: [ | ||
| { | ||
| type: 'text', | ||
| text: JSON.stringify({ | ||
| ok: true, | ||
| name: report.name, | ||
| spec_path: absSpecPath, | ||
| usage: { | ||
| manifest_external_device: { | ||
| type: 'ir', | ||
| connection: '<i2c peripheral id>', | ||
| config: { spec_path: absSpecPath }, | ||
| }, | ||
| }, | ||
| }, null, 2), | ||
| }, | ||
| ], | ||
| }; | ||
| } | ||
| if (name === 'labwired_ingest_svd') { | ||
| const { svd_content, filter } = IngestSvdInput.parse(args ?? {}); | ||
| const workspaceRoot = resolveWorkspaceRoot(); | ||
| const peripheralDir = join(workspaceRoot, '.labwired', 'peripherals'); | ||
| await mkdir(peripheralDir, { recursive: true }); | ||
| const work = await mkdtemp(join(tmpdir(), 'labwired-mcp-svd-')); | ||
| let report; | ||
| try { | ||
| const tmpSvd = join(work, 'in.svd'); | ||
| await writeFile(tmpSvd, svd_content); | ||
| const cliArgs = [ | ||
| 'asset', | ||
| 'ingest-svd', | ||
| '--input', | ||
| tmpSvd, | ||
| '--output-dir', | ||
| peripheralDir, | ||
| '--json', | ||
| ]; | ||
| if (filter) | ||
| cliArgs.push('--filter', filter); | ||
| const { stdout, stderr, exitCode } = await runCli(cliArgs); | ||
| try { | ||
| report = JSON.parse(stdout); | ||
| } | ||
| catch { | ||
| return { | ||
| content: [ | ||
| { | ||
| type: 'text', | ||
| text: JSON.stringify({ error: 'CLI_PARSE_ERROR', stdout, stderr, exit_code: exitCode }, null, 2), | ||
| }, | ||
| ], | ||
| isError: true, | ||
| }; | ||
| } | ||
| if (exitCode !== 0 || report.peripheral_count === 0) { | ||
| return { | ||
| content: [ | ||
| { | ||
| type: 'text', | ||
| text: JSON.stringify({ error: 'INGEST_FAILED', stderr, exit_code: exitCode, ...report }, null, 2), | ||
| }, | ||
| ], | ||
| isError: true, | ||
| }; | ||
| } | ||
| } | ||
| finally { | ||
| await rm(work, { recursive: true, force: true }).catch(() => { }); | ||
| } | ||
| // Read back each descriptor and build a paste-ready chip-yaml block. | ||
| const peripherals = []; | ||
| for (const p of report.peripherals) { | ||
| const absPath = resolve(p.descriptor_path); | ||
| const descriptor_yaml = await readFile(absPath, 'utf8').catch(() => ''); | ||
| peripherals.push({ ...p, descriptor_path: absPath, descriptor_yaml }); | ||
| } | ||
| const manifest_snippet = ['peripherals:'] | ||
| .concat(peripherals.map((p) => [ | ||
| ` - id: ${p.name.toLowerCase()}`, | ||
| ` type: declarative`, | ||
| ` base_address: ${p.base_address}`, | ||
| ` config:`, | ||
| ` path: ${p.descriptor_path}`, | ||
| ].join('\n'))) | ||
| .join('\n'); | ||
| return { | ||
| content: [ | ||
| { | ||
| type: 'text', | ||
| text: JSON.stringify({ | ||
| ok: true, | ||
| peripheral_count: report.peripheral_count, | ||
| peripherals, | ||
| manifest_snippet, | ||
| }, null, 2), | ||
| }, | ||
| ], | ||
| }; | ||
| } | ||
| if (name === 'labwired_verify') { | ||
| const a = (args ?? {}); | ||
| if (typeof a.system_yaml !== 'string' || !a.system_yaml.trim()) { | ||
| return { | ||
| content: [{ type: 'text', text: JSON.stringify({ ok: false, error: 'missing required: system_yaml' }, null, 2) }], | ||
| isError: true, | ||
| }; | ||
| } | ||
| // Ref-first firmware currency (same surface as labwired_run): inline | ||
| // firmware_base64 wins; otherwise resolve a content-addressed `sha256:<hex>` | ||
| // ref (firmware_ref or diagram.firmware.artifact.ref) from the blob store. | ||
| let firmwareBase64 = typeof a.firmware_base64 === 'string' ? a.firmware_base64 : ''; | ||
| if (!firmwareBase64.trim()) { | ||
| const ref = firmwareRefFromArgs(args); | ||
| if (!ref) { | ||
| return { | ||
| content: [ | ||
| { | ||
| type: 'text', | ||
| text: JSON.stringify({ | ||
| ok: false, | ||
| error: 'FIRMWARE_REQUIRED', | ||
| detail: 'Provide firmware_base64 or firmware_ref (sha256:<hex> from labwired_compile_firmware).', | ||
| }, null, 2), | ||
| }, | ||
| ], | ||
| isError: true, | ||
| }; | ||
| } | ||
| try { | ||
| firmwareBase64 = await resolveFirmwareRef(ref); | ||
| } | ||
| catch (e) { | ||
| return { | ||
| content: [ | ||
| { | ||
| type: 'text', | ||
| text: JSON.stringify({ ok: false, error: 'FIRMWARE_REF_UNRESOLVED', detail: e instanceof Error ? e.message : String(e) }, null, 2), | ||
| }, | ||
| ], | ||
| isError: true, | ||
| }; | ||
| } | ||
| } | ||
| const systemYaml = a.system_yaml; | ||
| const hasTestYaml = typeof a.test_yaml === 'string' && a.test_yaml.trim().length > 0; | ||
| const oracle = (a.oracle && typeof a.oracle === 'object' ? a.oracle : undefined); | ||
| const clauseCount = oracleClauseCount(oracle ?? null); | ||
| // Mandatory-oracle gate: verify needs EITHER a test_yaml (reference power | ||
| // path) OR an oracle with at least one clause. Empty → ORACLE_REQUIRED. | ||
| if (!hasTestYaml && clauseCount === 0) { | ||
| return { | ||
| content: [ | ||
| { | ||
| type: 'text', | ||
| text: JSON.stringify({ | ||
| error: 'ORACLE_REQUIRED', | ||
| detail: 'labwired_verify needs an oracle with at least one clause, or a test_yaml.', | ||
| }, null, 2), | ||
| }, | ||
| ], | ||
| isError: true, | ||
| }; | ||
| } | ||
| // Assemble a single OracleRunResult from whichever run path(s) the proof | ||
| // needs, then let the shared evaluateOracle mint the verdict. | ||
| const needBuilder = hasTestYaml || | ||
| (oracle?.serial?.length ?? 0) > 0 || | ||
| (oracle?.registers?.length ?? 0) > 0 || | ||
| (oracle?.gpio?.length ?? 0) > 0; | ||
| const needDevice = Boolean(oracle?.display); | ||
| const runResult = { status: 'pass', serial: '' }; | ||
| let builderResult; | ||
| let deviceResult; | ||
| if (needBuilder) { | ||
| // `labwired test` inside the container is the honest reference oracle for | ||
| // test_yaml assertions; for an oracle block with no test_yaml we run a | ||
| // minimal capture script so evaluateOracle can prove over the UART. | ||
| builderResult = await runBuildViaBuilder({ | ||
| firmwareBase64, | ||
| systemYaml, | ||
| testYaml: hasTestYaml ? a.test_yaml : synthCaptureTestYaml(), | ||
| }); | ||
| runResult.serial = builderResult.uart_excerpt ?? ''; | ||
| runResult.status = | ||
| builderResult.status || | ||
| (builderResult.ok ? (builderResult.passed ? 'pass' : 'incomplete') : 'error'); | ||
| } | ||
| if (needDevice) { | ||
| // run_device's painted/refresh/ink readout maps onto the display oracle. | ||
| deviceResult = await runDeviceCapture({ firmwareBase64, systemYaml }); | ||
| runResult.peripherals = [ | ||
| { | ||
| name: deviceResult.panel ?? 'panel', | ||
| kind: 'epaper_panel', | ||
| artifacts: [ | ||
| { | ||
| kind: deviceResult.panel ? `epaper_panel_${deviceResult.panel}` : 'epaper_panel', | ||
| meta: { | ||
| refresh_generation: deviceResult.refresh_generation, | ||
| black_ink_bytes: deviceResult.ink_bytes, | ||
| ink_bytes: deviceResult.ink_bytes, | ||
| }, | ||
| }, | ||
| ], | ||
| }, | ||
| ]; | ||
| if (!needBuilder) | ||
| runResult.status = deviceResult.exit_code === 0 ? 'pass' : 'error'; | ||
| } | ||
| let proven; | ||
| let verification = ''; | ||
| let oracle_results; | ||
| let unevaluable_clauses; | ||
| if (oracle && clauseCount > 0) { | ||
| const verdict = evaluateOracle(oracle, runResult); | ||
| proven = verdict.passed; | ||
| oracle_results = verdict.clauses.map((c) => ({ kind: c.kind, passed: c.passed, detail: c.detail })); | ||
| // register/gpio clauses need the hosted /run peripherals inspect block, | ||
| // which the stdio builder run-build result does not carry — flag them. | ||
| const needsInspect = (oracle.registers?.length ?? 0) + (oracle.gpio?.length ?? 0); | ||
| if (needsInspect > 0) { | ||
| unevaluable_clauses = | ||
| 'register/gpio clauses require the hosted /run peripheral-inspect block; the stdio ' + | ||
| 'verify path only observes serial (UART) + display. Those clauses were evaluated against ' + | ||
| 'an empty peripheral set and cannot pass here — use the hosted verify for them.'; | ||
| } | ||
| verification = proven | ||
| ? `Oracle proven server-side (deterministic): ${oracle_results | ||
| .filter((c) => c.passed) | ||
| .map((c) => c.detail) | ||
| .join('; ')}.` | ||
| : ''; | ||
| } | ||
| else { | ||
| // test_yaml-only reference path: the builder's assertion verdict rules. | ||
| proven = Boolean(builderResult?.ok && builderResult?.passed); | ||
| verification = proven ? 'Test assertions passed server-side (deterministic oracle run).' : ''; | ||
| } | ||
| const ok = needBuilder ? Boolean(builderResult?.ok) : true; | ||
| const payload = { | ||
| ok, | ||
| proven, | ||
| verification, | ||
| status: builderResult?.status ?? runResult.status, | ||
| stop_reason: builderResult?.stop_reason, | ||
| assertions: builderResult?.assertions, | ||
| oracle_results, | ||
| ...(unevaluable_clauses ? { unevaluable_clauses } : {}), | ||
| verdict_lines: builderResult?.verdict_lines, | ||
| uart_excerpt: builderResult?.uart_excerpt, | ||
| display: deviceResult | ||
| ? { | ||
| painted: deviceResult.painted, | ||
| panel: deviceResult.panel, | ||
| refresh_generation: deviceResult.refresh_generation, | ||
| ink_bytes: deviceResult.ink_bytes, | ||
| readout: deviceResult.readout, | ||
| } | ||
| : undefined, | ||
| error: builderResult?.error, | ||
| }; | ||
| return { | ||
| content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], | ||
| isError: !ok || !proven, | ||
| }; | ||
| } | ||
| if (name === 'labwired_inspect_run') { | ||
| const { snapshot_id, scope } = InspectRunInput.parse(args ?? {}); | ||
| const snap = getSnapshot(snapshot_id); | ||
| if (!snap) { | ||
| return { | ||
| content: [ | ||
| { | ||
| type: 'text', | ||
| text: JSON.stringify({ | ||
| error: 'SNAPSHOT_EXPIRED', | ||
| message: `Snapshot ${snapshot_id} not found. TTL is 10 minutes; re-run labwired_run.`, | ||
| }, null, 2), | ||
| }, | ||
| ], | ||
| isError: true, | ||
| }; | ||
| } | ||
| const payload = { snapshot_id, scope, board_id: snap.board_id }; | ||
| if (scope === 'summary' || scope === 'raw') { | ||
| payload.final_cycles = snap.final_cycles; | ||
| payload.final_pc_hex = snap.final_pc_hex; | ||
| } | ||
| if (scope === 'serial' || scope === 'summary') { | ||
| payload.serial_output = snap.serial_output ?? ''; | ||
| } | ||
| if (scope === 'gpio') { | ||
| payload.gpio_events = snap.gpio_events ?? []; | ||
| payload.gpio_truncated = snap.gpio_truncated ?? false; | ||
| } | ||
| if (scope === 'raw') { | ||
| payload.raw_result = snap.raw_result; | ||
| } | ||
| return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] }; | ||
| } | ||
| return { | ||
@@ -154,0 +1304,0 @@ content: [{ type: 'text', text: `Unknown tool: ${name}` }], |
+4
-2
| { | ||
| "name": "@labwired/mcp", | ||
| "version": "0.1.1", | ||
| "version": "0.6.0", | ||
| "description": "Model Context Protocol server exposing the LabWired deterministic firmware simulator as agent tools.", | ||
@@ -15,4 +15,5 @@ "license": "MIT", | ||
| "scripts": { | ||
| "build": "tsc", | ||
| "build": "npm --prefix ../board-config run build && tsc", | ||
| "dev": "tsx src/index.ts", | ||
| "pretest": "npm run build", | ||
| "test": "vitest run", | ||
@@ -22,2 +23,3 @@ "prepublishOnly": "npm run build" | ||
| "dependencies": { | ||
| "@labwired/board-config": "file:../board-config", | ||
| "@modelcontextprotocol/sdk": "^1.20.0", | ||
@@ -24,0 +26,0 @@ "zod": "^3.25.0" |
+49
-12
@@ -5,12 +5,42 @@ # @labwired/mcp | ||
| LabWired runs ARM Cortex-M / RISC-V / Xtensa firmware against a cycle-accurate, deterministic simulator. This MCP server lets your agent (Claude Code, Cursor, Continue, etc.) **verify firmware behaviour without lying to itself** — same inputs always produce the same outputs, so the agent can iterate against a real oracle. | ||
| ## One-line install (Claude Code) | ||
| ```bash | ||
| claude mcp add labwired -- npx -y @labwired/mcp | ||
| ``` | ||
| That's it. Restart Claude Code if the tools don't appear, then ask: *"List LabWired boards and run my ELF on `stm32f103-blinky`."* | ||
| Cursor / other MCP clients: see [Other clients](#install-in-cursor) below. | ||
| --- | ||
| LabWired runs ARM Cortex-M / RISC-V / Xtensa firmware against a silicon-validated, deterministic simulator. This MCP server lets your agent (Claude Code, Cursor, Continue, etc.) **verify firmware behaviour without lying to itself** — same inputs always produce the same outputs, so the agent can iterate against a real oracle. | ||
| ## What it gives your agent | ||
| **Core workflow:** | ||
| | Tool | What it does | | ||
| |---|---| | ||
| | `labwired_catalog` | List supported chips/boards (filter by name). | | ||
| | `labwired_simulate` | Run firmware against a test script. Returns `result.json` + UART log. Deterministic. | | ||
| | `labwired_list_boards` | High-level: list pre-wired boards (chip + peripherals + demo firmware). Start here. | | ||
| | `labwired_run_lab` | High-level: run an ELF on a board by id. Synthesizes the test script, returns cycles + serial + a `snapshot_id`. | | ||
| | `labwired_fuzz` | Coverage-guided fuzz a firmware ELF and return the crashing inputs. Silicon-validated sim → crashes are replayable on real hardware (HIL-confirm), not emulation false positives. Deterministic for a fixed seed. | | ||
| | `labwired_inspect_run` | Fetch detail from a prior `snapshot_id` — `summary`, `serial`, `gpio`, `raw`. 10-min TTL. | | ||
| | `labwired_catalog` | Low-level: list raw chip descriptors. | | ||
| | `labwired_simulate` | Low-level: run firmware against a custom System Manifest + test script YAML. Full control. | | ||
| | `labwired_validate_system` | Validate a System Manifest YAML before simulating. Fast schema check. | | ||
| | `labwired_validate_diagram` | Structurally validate a wired diagram (parts + wires) before running. Returns machine-readable diagnostics (`PIN_NOT_ON_CHIP`, `PIN_LACKS_I2C`, `BOARDIO_NOT_TO_MCU`, `NO_MCU`, `COMPONENT_DANGLING`, …) with suggested fixes. | | ||
| | `labwired_compile_diagram` | Compile a validated diagram to a runnable board manifest (ERC-gated; net-derived I2C bus binding). Persists the manifest to `.labwired/boards/<name>.yaml` and returns `board_path` + `system_yaml`. Errors abort with diagnostics; warnings are included in the response. | | ||
| | `labwired_define_component` | Define a new off-chip device from a declarative IR spec; validated, persisted, ready to wire into a manifest. | | ||
| **Live agent → browser bridge** (optional — for showing humans what the agent is doing): | ||
| | Tool | What it does | | ||
| |---|---| | ||
| | `labwired_create_session` | Open a watch session; returns a `watch_url` like `https://app.labwired.com/?watch=<id>`. Subsequent runs mirror to it automatically over WebSocket. | | ||
| | `labwired_set_diagram` | Push the current circuit (parts + wires) to the watch session. | | ||
| | `labwired_set_source` | Push the current source code to the watch session. | | ||
| | `labwired_end_session` | Close the watch session. | | ||
| ## Prerequisites | ||
@@ -26,10 +56,2 @@ | ||
| ## Install in Claude Code | ||
| ```bash | ||
| claude mcp add labwired -- npx -y @labwired/mcp | ||
| ``` | ||
| That's it. Restart Claude Code if the tools don't appear immediately. | ||
| ## Install in Cursor | ||
@@ -70,2 +92,3 @@ | ||
| - `LABWIRED_CLI` — override the CLI binary path. Defaults to `labwired` on `PATH`. | ||
| - `LABWIRED_REPO_ROOT` — absolute path to your labwired checkout. The high-level `labwired_run_lab` tool resolves board YAMLs from `core/configs/`. Auto-detected when the MCP server is run from inside the repo; required when running globally. | ||
@@ -80,4 +103,18 @@ ## Why deterministic matters for agents | ||
| Early MVP (v0.1). Three tools, stdio transport, no auth required for local use (the simulator runs on your machine). Worker-side metering for hosted runs lands in a future version. | ||
| v0.7 — sixteen tools (fifteen plus the `labwired_search_tools` meta-tool), stdio transport, no auth required for local use (the simulator runs on your machine). | ||
| Shipped: | ||
| - **v0.1** — `labwired_catalog` / `labwired_simulate` / `labwired_validate_system` (raw chip + YAML workflow). | ||
| - **v0.2** — high-level board-centric workflow: `labwired_list_boards` / `labwired_run_lab` / `labwired_inspect_run`. ELF-in only — agents compile locally. | ||
| - **v0.3** — live agent → browser bridge: `labwired_create_session` / `labwired_set_diagram` / `labwired_set_source` / `labwired_end_session`. Runs stream over WebSocket to `https://app.labwired.com/?watch=<id>`. | ||
| - **v0.4** — `labwired_validate_diagram` with machine-readable diagnostics (pin/wire/bus errors + suggested fixes). | ||
| - **v0.5** — `labwired_fuzz`: coverage-guided firmware fuzzing in the silicon-validated sim; crashes are HIL-replayable, not emulation false positives. | ||
| - **v0.6** — `labwired_define_component`: agent-authored declarative IR specs for off-chip I2C devices; `type: ir` manifest integration; PCA9685 and TMP102 reference specs gated by byte-equivalence tests. | ||
| - **v0.7** — Wiring kernel: named nets, electrical rule checks (ERC), and ERC-gated diagram compilation on both MCP surfaces. `labwired_validate_diagram` upgraded with full kernel ERC (I2C bus rules, driver conflicts, voltage mismatches, IRQ ordinal validation). New `labwired_compile_diagram` compiles a diagram to a runnable board manifest with net-derived I2C bus binding. | ||
| Next: | ||
| - **v0.8** — Worker-side metering for hosted runs. | ||
| Issues / requests: <https://github.com/w1ne/labwired/issues>. | ||
@@ -84,0 +121,0 @@ |
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Network access
Supply chain riskThis module accesses the network.
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 6 instances
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 2 instances
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
102243
616.79%10
150%2090
682.77%121
44.05%3
50%16
300%4
300%