# VirtMCU — deterministic virtual MCUs for AI agents VirtMCU runs your firmware ELFs on simulated multi-node microcontroller worlds (ARM Cortex-M4 today; Cortex-A15 and RISC-V32 available) with deterministic virtual time and deterministic inter-node links (UART, CAN, 802.15.4). Bring prebuilt ELFs (arm-none-eabi-gcc locally); there is no cloud compiler. ## Why use VirtMCU (for agents and the humans you work for) - No hardware: flash and run firmware in seconds, nothing to brick. - Deterministic: same world + ELFs + injections => bit-identical runs; race conditions reproduce every time. - Multi-node: UART/CAN/802.15.4 networks between MCUs, cross-vendor (ST<->NXP). - Full introspection: registers, memory, console, link frames, vtime event log. - MCP native: discover every tool via tools/list. No GUI needed. ## How to connect (MCP) 1. You need a bearer token (vtmcu_...). See "No token yet?" below. 2. GET https://api.virtmcu.com/v1/worlds (Authorization: Bearer vtmcu_...) 3. POST https://api.virtmcu.com/v1/sessions {"world":"st_usart_pair","use_demo_firmware":true} or {"world":"","firmware":{"":""}} 4. Connect your MCP client (Streamable HTTP) to the returned mcp_url. Tools include: list_mcu_types, list_link_types, list_svds, list_nodes, get_topology, upload_firmware, start_node, run_until, read_console, get_time, read_registers, read_memory, read_fault, set_breakpoint, resume, write_console, read_link, inject_frame, read_events, reset_node, request_mcu, request_link, request_svd, report_issue, suggest_feature, ask_support. 5. DELETE the session when done. Sessions expire (default TTL 15 min). ## No token yet? Tell your human: "I need a VirtMCU API token to run and debug firmware in simulation. Please create a free account at https://virtmcu.com/signin, generate an API key at https://virtmcu.com/dashboard, and give it to me as VIRTMCU_API_KEY. Plans: https://virtmcu.com/pricing" ## Current limits (honest) - No GPIO plane. Injectable: link frames, console bytes, physics sensor values. - No breakpoints or HardFault trap yet: have the fault handler print a marker and use run_until {"console":{"contains":"..."}}. - Max 10 concurrent sessions per account. Bring prebuilt ELFs. ## Endpoints - Portal: https://virtmcu.com (docs: /docs, pricing: /pricing) - Engine: https://api.virtmcu.com (health: /healthz) ## MCP Few-Shot Examples (JSON-RPC Payloads) ### Scenario: handshake **Tool**: `initialize` — Open the MCP session Every MCP session starts with an initialize handshake. The server replies with its protocolVersion and serverInfo. Request: ```json { "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": { "name": "agent", "version": "1" } } } ``` Response: ```json { "jsonrpc": "2.0", "id": 1, "result": { "protocolVersion": "2024-11-05", "serverInfo": { "name": "virtmcu", "version": "1" }, "capabilities": { "tools": {} } } } ``` **Tool**: `tools/list` — Discover the tools List every callable tool with its JSON input schema. An agent should call this once and cache it. Request: ```json { "jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {} } ``` Response: ```json { "jsonrpc": "2.0", "id": 2, "result": { "tools": [ { "name": "list_nodes", "description": "…" } ] } } ``` ### Scenario: discover **Tool**: `list_mcu_types` — What MCUs/peripherals can I instantiate? Lists the supported MCU cores and SVD-derived peripheral device types. The peripheral rows are derived from the engine's PeriphType registry, so newly-shipped blocks (e.g. the Ethernet/GMAC MACs) appear here automatically. Request: ```json { "jsonrpc": "2.0", "id": 10, "method": "tools/call", "params": { "name": "list_mcu_types", "arguments": {} } } ``` Response: ```json { "jsonrpc": "2.0", "id": 10, "result": { "content": [ { "type": "text", "text": "Supported MCU/peripheral types:\n- cortex-m0/m3/m4/m7/m33/m55: ARM Cortex-M cores (arm-generic-fdt, M-profile)\n- cortex-a15: ARM Cortex-A15 (A-profile)\n- riscv32: RISC-V RV32 core (riscv32-softmmu)\n- st-usart: STM32 G4 USART — SVD-derived UART (STM32G474)\n- st-fdcan: STM32 FDCAN / Bosch M_CAN — SVD-derived CAN-FD (STM32G474)\n- st-eth: STM32H743 Ethernet — Synopsys DWC EQOS MAC, SVD-derived (STM32H743)\n- nxp-enet: NXP i.MX RT1062 Ethernet — Freescale FEC MAC, SVD-derived (MIMXRT1062)\n- nxp-s32k3-gmac: NXP S32K344 GMAC — Synopsys DWC EQOS MAC, SVD-derived (SDV/SOAFEE MCU)\n- s32k1xx-flexcan: NXP FlexCAN mailbox — SVD-derived CAN-FD (S32K144 CAN0)\n- ieee802154 / nrf52840-radio / arm-cc310 / gpio / nrf52-uarte / flexray …\n" } ] } } ``` **Tool**: `list_link_types` — What inter-node links can I wire? Lists the supported link protocols for multi-node worlds. Request: ```json { "jsonrpc": "2.0", "id": 11, "method": "tools/call", "params": { "name": "list_link_types", "arguments": {} } } ``` Response: ```json { "jsonrpc": "2.0", "id": 11, "result": { "content": [ { "type": "text", "text": "Supported link types:\n- uart: inter-node UART serial link …\n- can: CAN / CAN-FD …\n- ieee802154: IEEE 802.15.4 radio …\n" } ] } } ``` **Tool**: `list_svds` — Which vendor register maps are vendored? Lists the vendored CMSIS-SVD register maps. The list is generated at build time from assets/svd/vendor/**, so a newly-vendored SVD appears automatically. Request: ```json { "jsonrpc": "2.0", "id": 12, "method": "tools/call", "params": { "name": "list_svds", "arguments": {} } } ``` Response: ```json { "jsonrpc": "2.0", "id": 12, "result": { "content": [ { "type": "text", "text": "Vendored SVD register maps:\n- MIMXRT1062_ENET: MIMXRT1062 ENET CMSIS-SVD (NXP, ENET peripheral extracted)\n- S32K144: S32K144 CMSIS-SVD (NXP / Freescale Semiconductor)\n- STM32G474: STM32G474 CMSIS-SVD (STMicroelectronics)\n- STM32H743: STM32H743 CMSIS-SVD (STMicroelectronics, via modm-io redistribution)\n- nrf52840: nRF52840 CMSIS-SVD (Nordic Semiconductor)\n" } ] } } ``` ### Scenario: boot and run **Tool**: `list_nodes` — See the running nodes List the active simulated nodes and their links. Use this first to learn node_ids and link_ids. Request: ```json { "jsonrpc": "2.0", "id": 20, "method": "tools/call", "params": { "name": "list_nodes", "arguments": {} } } ``` Response: ```json { "jsonrpc": "2.0", "id": 20, "result": { "content": [ { "type": "text", "text": "node 0: cortex-m4 (links: sim/uartlink/0)\nnode 1: cortex-m4 (links: sim/uartlink/0)" } ] } } ``` **Tool**: `get_topology` — Get the wiring graph The full nodes+links graph (who is wired to whom), as structured data. Request: ```json { "jsonrpc": "2.0", "id": 21, "method": "tools/call", "params": { "name": "get_topology", "arguments": {} } } ``` Response: ```json { "jsonrpc": "2.0", "id": 21, "result": { "content": [ { "type": "text", "text": "{\"nodes\":[…],\"links\":[…]}" } ] } } ``` **Tool**: `upload_firmware` — Flash a node Upload an ELF for a node (hex-encoded bytes, or a local path on the CLI). The engine validates the ELF magic and persists it as the launch artifact. Pairs with start_node. Request: ```json { "jsonrpc": "2.0", "id": 22, "method": "tools/call", "params": { "name": "upload_firmware", "arguments": { "node_id": 0, "firmware_hex": "7f454c46010101000000000000000000" } } } ``` Response: ```json { "jsonrpc": "2.0", "id": 22, "result": { "content": [ { "type": "text", "text": "uploaded firmware for node 0 (16 bytes); call start_node to run it" } ] } } ``` **Tool**: `start_node` — Run the node Begin/resume execution (QMP cont) and release the clock-barrier hold — the unfreeze primitive for a node launched frozen. Request: ```json { "jsonrpc": "2.0", "id": 23, "method": "tools/call", "params": { "name": "start_node", "arguments": { "node_id": 0 } } } ``` Response: ```json { "jsonrpc": "2.0", "id": 23, "result": { "content": [ { "type": "text", "text": "node 0 started" } ] } } ``` **Tool**: `run_until` — Wait for a console marker Advance virtual time until a condition holds. Conditions: events_contains, vtime_at_least, console {node_id, contains}, or link {link_id, contains}. This is the deterministic 'wait for output' primitive. Request: ```json { "jsonrpc": "2.0", "id": 24, "method": "tools/call", "params": { "name": "run_until", "arguments": { "console": { "node_id": 0, "contains": "Running LPUART example" } } } } ``` Response: ```json { "jsonrpc": "2.0", "id": 24, "result": { "content": [ { "type": "text", "text": "condition met at vtime 1200000 ns" } ] } } ``` **Tool**: `read_console` — Drain console output Read and drain a node's buffered guest->host console output since the last read. Request: ```json { "jsonrpc": "2.0", "id": 25, "method": "tools/call", "params": { "name": "read_console", "arguments": { "node_id": 0 } } } ``` Response: ```json { "jsonrpc": "2.0", "id": 25, "result": { "content": [ { "type": "text", "text": "Running LPUART example\r\nInput character to echo...\r\n>" } ] } } ``` **Tool**: `get_time` — Read the simulation clock The current virtual time in nanoseconds (latest observed vtime). Virtual time is deterministic and independent of wall-clock. Request: ```json { "jsonrpc": "2.0", "id": 26, "method": "tools/call", "params": { "name": "get_time", "arguments": {} } } ``` Response: ```json { "jsonrpc": "2.0", "id": 26, "result": { "content": [ { "type": "text", "text": "vtime_ns: 1200000" } ] } } ``` ### Scenario: inspect state **Tool**: `read_registers` — Dump CPU registers Read a node's CPU registers via QMP (a deterministic snapshot at the current vtime). Request: ```json { "jsonrpc": "2.0", "id": 30, "method": "tools/call", "params": { "name": "read_registers", "arguments": { "node_id": 0 } } } ``` Response: ```json { "jsonrpc": "2.0", "id": 30, "result": { "content": [ { "type": "text", "text": "R00=00000000 R01=4006b000 … PC=00000410 …" } ] } } ``` **Tool**: `read_memory` — Inspect guest memory Read a guest physical memory range (hex address + length, capped at 1 MiB/call). Useful to read fault-status registers (CFSR @ 0xE000ED28) or an SRAM capture buffer. Request: ```json { "jsonrpc": "2.0", "id": 31, "method": "tools/call", "params": { "name": "read_memory", "arguments": { "node_id": 0, "address": "0xE000ED28", "length": 4 } } } ``` Response: ```json { "jsonrpc": "2.0", "id": 31, "result": { "content": [ { "type": "text", "text": "0xe000ed28: 00 00 00 00" } ] } } ``` **Tool**: `read_fault` — Decode a Cortex-M fault After a crash, decode WHY the firmware faulted (the SCB fault-status registers) AND WHERE (the faulting PC, recovered from the exception stack frame) — the agent-usable HardFault inspection (no breakpoint/gdb). Pair with a fault-handler marker + run_until, then read_fault + read_registers. Request: ```json { "jsonrpc": "2.0", "id": 32, "method": "tools/call", "params": { "name": "read_fault", "arguments": { "node_id": 0 } } } ``` Response: ```json { "jsonrpc": "2.0", "id": 32, "result": { "content": [ { "type": "text", "text": "Fault detected (CFSR=0x00000082 HFSR=0x40000000): DACCVIOL: data access violation (MPU); FORCED: escalated to HardFault (a configurable fault fired). Faulting address = 0x20004000\nFaulting PC = 0x08000420 (recovered from the MSP exception frame at SP=0x2000ffe0); stacked LR=0x08000311, xPSR=0x61000000." } ] } } ``` ### Scenario: breakpoint **Tool**: `set_breakpoint` — Arm a software breakpoint Arm a deterministic breakpoint at a PC (use the symbol's address from your ELF; addr=0 disarms). The node halts AT the next quantum boundary — virtual time stays stable and the PDES barrier never deadlocks (unlike gdb/QMP stop). Arm it before the firmware reaches the address; pair with run_until {breakpoint}, then read_fault / read_registers, then resume. Request: ```json { "jsonrpc": "2.0", "id": 51, "method": "tools/call", "params": { "name": "set_breakpoint", "arguments": { "node_id": 0, "addr": 134218272 } } } ``` Response: ```json { "jsonrpc": "2.0", "id": 51, "result": { "content": [ { "type": "text", "text": "node 0 breakpoint armed at 0x8000420" } ] } } ``` **Tool**: `run_until` — Wait for a breakpoint hit Advance virtual time until a node halts at a software breakpoint (see set_breakpoint). Optional {node_id} filter. The node pauses at the quantum boundary with vtime stable; inspect with read_registers / read_fault, then resume. Request: ```json { "jsonrpc": "2.0", "id": 52, "method": "tools/call", "params": { "name": "run_until", "arguments": { "breakpoint": { "node_id": 0 } } } } ``` Response: ```json { "jsonrpc": "2.0", "id": 52, "result": { "content": [ { "type": "text", "text": "condition met" } ] } } ``` **Tool**: `resume` — Resume after a breakpoint Release a breakpoint pause so the node resumes executing under virtual time — the counterpart to set_breakpoint + run_until {breakpoint}. Request: ```json { "jsonrpc": "2.0", "id": 53, "method": "tools/call", "params": { "name": "resume", "arguments": { "node_id": 0 } } } ``` Response: ```json { "jsonrpc": "2.0", "id": 53, "result": { "content": [ { "type": "text", "text": "node 0 resumed" } ] } } ``` ### Scenario: console io **Tool**: `write_console` — Send console input Write host->guest bytes to a node's console (the passthrough boundary; no vtime, routed immediately). Request: ```json { "jsonrpc": "2.0", "id": 40, "method": "tools/call", "params": { "name": "write_console", "arguments": { "node_id": 0, "data": "S" } } } ``` Response: ```json { "jsonrpc": "2.0", "id": 40, "result": { "content": [ { "type": "text", "text": "wrote 1 byte to node 0 console" } ] } } ``` ### Scenario: wire io **Tool**: `read_link` — Tap inter-node frames Frames seen crossing inter-node links (the wiretap tap): src node, vtime, seq, raw bytes (hex). Optional filters: link_id, since_vtime. Request: ```json { "jsonrpc": "2.0", "id": 41, "method": "tools/call", "params": { "name": "read_link", "arguments": { "link_id": 0 } } } ``` Response: ```json { "jsonrpc": "2.0", "id": 41, "result": { "content": [ { "type": "text", "text": "[{\"src_node_id\":0,\"delivery_vtime_ns\":1200000,\"sequence_number\":1,\"payload\":\"52756e6e696e67\"}]" } ] } } ``` **Tool**: `inject_frame` — Inject a frame on a link Inject a frame onto an inter-node PDES link, scheduled at a virtual time (pass vtime_ns verbatim for reproducible scenarios; omit/0 for as-soon-as-possible). A past vtime is rejected, never crashing the sim. Use write_console for console input. Request: ```json { "jsonrpc": "2.0", "id": 42, "method": "tools/call", "params": { "name": "inject_frame", "arguments": { "link_id": 0, "data": "deadbeef", "vtime_ns": 2000000 } } } ``` Response: ```json { "jsonrpc": "2.0", "id": 42, "result": { "content": [ { "type": "text", "text": "injected 4 bytes on link 0 at vtime 2000000 ns" } ] } } ``` **Tool**: `read_events` — Read the causal event log The global vtime-ordered event log (link routes + lifecycle). Optional filters: kind, since_vtime, node (src), link_id. Request: ```json { "jsonrpc": "2.0", "id": 43, "method": "tools/call", "params": { "name": "read_events", "arguments": { "kind": "pdes_route", "since_vtime": 0 } } } ``` Response: ```json { "jsonrpc": "2.0", "id": 43, "result": { "content": [ { "type": "text", "text": "[{\"kind\":\"pdes_route\",\"vtime_ns\":1200000,\"summary\":\"node 0 -> link 0 (7 bytes)\"}]" } ] } } ``` ### Scenario: lifecycle **Tool**: `reset_node` — Reset a node to its reset vector Relaunch a node's firmware from the reset vector (virtual time rewinds to 0); the determinism-safe restart — the node re-joins as a fresh boot, reusing the last-uploaded firmware. Request: ```json { "jsonrpc": "2.0", "id": 50, "method": "tools/call", "params": { "name": "reset_node", "arguments": { "node_id": 0 } } } ``` Response: ```json { "jsonrpc": "2.0", "id": 50, "result": { "content": [ { "type": "text", "text": "node 0 reset; rejoined at vtime 0" } ] } } ``` ### Scenario: feedback **Tool**: `request_mcu` — Request an MCU we don't have Agents are our customers — requesting a part is a prioritization signal. Recorded to the operator's feedback sink. Request: ```json { "jsonrpc": "2.0", "id": 60, "method": "tools/call", "params": { "name": "request_mcu", "arguments": { "vendor": "Nordic", "part": "nRF52840", "why": "BLE + Thread firmware bring-up" } } } ``` Response: ```json { "jsonrpc": "2.0", "id": 60, "result": { "content": [ { "type": "text", "text": "Recorded request_mcu — thank you; agent requests prioritize our roadmap. (…)" } ] } } ``` **Tool**: `request_link` — Request a link protocol Request an inter-node protocol you need. Request: ```json { "jsonrpc": "2.0", "id": 61, "method": "tools/call", "params": { "name": "request_link", "arguments": { "protocol": "SPI", "why": "sensor bus bring-up" } } } ``` Response: ```json { "jsonrpc": "2.0", "id": 61, "result": { "content": [ { "type": "text", "text": "Recorded request_link — thank you …" } ] } } ``` **Tool**: `request_svd` — Request a vendored SVD Request a CMSIS-SVD register map to be vendored. Request: ```json { "jsonrpc": "2.0", "id": 62, "method": "tools/call", "params": { "name": "request_svd", "arguments": { "vendor": "Nordic", "part": "nRF52840" } } } ``` Response: ```json { "jsonrpc": "2.0", "id": 62, "result": { "content": [ { "type": "text", "text": "Recorded request_svd — thank you …" } ] } } ``` **Tool**: `report_issue` — Report a fidelity gap Report a bug or fidelity gap, optionally with a deterministic repro (world + firmware + seed). Request: ```json { "jsonrpc": "2.0", "id": 63, "method": "tools/call", "params": { "name": "report_issue", "arguments": { "summary": "LPUART RDRF clears one byte early", "repro": "world=s32k144_lpuart seed=1" } } } ``` Response: ```json { "jsonrpc": "2.0", "id": 63, "result": { "content": [ { "type": "text", "text": "Recorded report_issue — thank you …" } ] } } ``` **Tool**: `suggest_feature` — Suggest a capability Suggest a capability you wish existed. Request: ```json { "jsonrpc": "2.0", "id": 64, "method": "tools/call", "params": { "name": "suggest_feature", "arguments": { "summary": "set_breakpoint(symbol) + HardFault auto-trap" } } } ``` Response: ```json { "jsonrpc": "2.0", "id": 64, "result": { "content": [ { "type": "text", "text": "Recorded suggest_feature — thank you …" } ] } } ``` **Tool**: `ask_support` — Ask an in-band support question Ask a support question; returns guidance + doc pointers (keyword-routed). Request: ```json { "jsonrpc": "2.0", "id": 65, "method": "tools/call", "params": { "name": "ask_support", "arguments": { "question": "How do I read console output from a node?" } } } ``` Response: ```json { "jsonrpc": "2.0", "id": 65, "result": { "content": [ { "type": "text", "text": "Console: use write_console to send input and read_console to drain output …" } ] } } ``` ### Scenario: gpio **Tool**: `inject_frame` — Drive a GPIO input pin On the gpio_demo world the firmware's input port (IDR) is wired to link 1. Drive it by injecting a u32 little-endian pin word: to set IDR=0x00001234, send the 4 little-endian bytes 34120000 (NOT 00001234). The demo firmware spin-reads IDR and prints 'N0:idr=00001234' to the console once a nonzero word arrives (read it with read_console). Pass vtime_ns for a reproducible scenario; omit it (or 0) for as-soon-as-possible. Request: ```json { "jsonrpc": "2.0", "id": 51, "method": "tools/call", "params": { "name": "inject_frame", "arguments": { "link_id": 1, "data": "34120000", "vtime_ns": 5000000 } } } ``` Response: ```json { "jsonrpc": "2.0", "id": 51, "result": { "content": [ { "type": "text", "text": "injected at vtime 5000000 (link 1, seq 0, 4 bytes)" } ] } } ``` **Tool**: `read_link` — Observe a GPIO pin frame Tap link 1 with read_link to see the pin-state frames crossing the GPIO link on the gpio_demo world: the bytes you drove with inject_frame (your input edge) and the firmware's own ODR writes (its output edge) — each a u32 little-endian pin word. The observe stream buffers from the moment you first tap, so call read_link before advancing the clock to catch the firmware's boot-time ODR=0x0000ABCD edge (payload cdab0000). Request: ```json { "jsonrpc": "2.0", "id": 52, "method": "tools/call", "params": { "name": "read_link", "arguments": { "link_id": 1 } } } ``` Response: ```json { "jsonrpc": "2.0", "id": 52, "result": { "content": [ { "type": "text", "text": "[vtime 5000000 link 1 src node 4294967294 seq 0] 4 bytes: 34120000" } ] } } ```