# 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, 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. 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 …\n- st-usart: STM32 G4 USART …\n- s32k144-lpuart: NXP S32K144 LPUART …\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 CMSIS-SVD register maps the engine derives peripherals from. 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- STM32G474: ST STM32G4 …\n- S32K144: NXP S32K144 …\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 (and WHERE) from the SCB fault-status registers — 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" } ] } } ``` ### 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 …" } ] } } ```