Five minutes for the human, then the agent does the rest.
export VIRTMCU_API_KEY="vtmcu_..."# 0. Everything needs your token:
H='Authorization: Bearer '"$VIRTMCU_API_KEY"
# 1. Pick a world (multi-node topologies: UART pairs, CAN pairs, cross-vendor...)
curl -s https://api.virtmcu.com/v1/worlds -H "$H"
# 2. Create a session (demo firmware, or bring your own ELFs as base64)
curl -s -X POST https://api.virtmcu.com/v1/sessions \
-H "$H" -H "Content-Type: application/json" \
-d '{"world":"st_usart_pair","use_demo_firmware":true}'
# -> {"session_id":"...","mcp_url":"https://api.virtmcu.com/v1/sessions/<id>/mcp",...}
# 3. Connect your MCP client (Streamable HTTP) to mcp_url, then:
# initialize -> tools/list -> tools/call
# e.g. start_node(0), start_node(1),
# run_until {"console":{"node_id":"0","contains":"READY"}},
# read_registers(0), read_link(...), read_events()
# 4. Tear down when done
curl -s -X DELETE https://api.virtmcu.com/v1/sessions/<id> -H "$H"Machine-readable onboarding: /llms.txt and /agents.json. No token? Ask your human (see the note on /pricing).
The live source of truth is tools/list on an open session.
| TOOL | DESCRIPTION |
|---|---|
| list_mcu_types | What MCUs/peripherals can I instantiate? |
| list_link_types | What inter-node links can I wire? |
| list_svds | Which vendor register maps are vendored? |
| list_nodes | See the running nodes |
| get_topology | Get the wiring graph |
| upload_firmware | Flash a node |
| start_node | Run the node |
| run_until | Wait for a breakpoint hit |
| read_console | Drain console output |
| get_time | Read the simulation clock |
| read_registers | Dump CPU registers |
| read_memory | Inspect guest memory |
| read_fault | Decode a Cortex-M fault |
| set_breakpoint | Arm a software breakpoint |
| resume | Resume after a breakpoint |
| write_console | Send console input |
| read_link | Observe a GPIO pin frame |
| inject_frame | Drive a GPIO input pin |
| read_events | Read the causal event log |
| reset_node | Reset a node to its reset vector |
| request_mcu | Request an MCU we don't have |
| request_link | Request a link protocol |
| request_svd | Request a vendored SVD |
| report_issue | Report a fidelity gap |
| suggest_feature | Suggest a capability |
| ask_support | Ask an in-band support question |
Real JSON-RPC payloads your agent will use, dynamically synced from the VirtMCU engine contract.
Every MCP session starts with an initialize handshake. The server replies with its protocolVersion and serverInfo.
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {
"name": "agent",
"version": "1"
}
}
}{
"jsonrpc": "2.0",
"id": 1,
"result": {
"protocolVersion": "2024-11-05",
"serverInfo": {
"name": "virtmcu",
"version": "1"
},
"capabilities": {
"tools": {}
}
}
}List every callable tool with its JSON input schema. An agent should call this once and cache it.
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/list",
"params": {}
}{
"jsonrpc": "2.0",
"id": 2,
"result": {
"tools": [
{
"name": "list_nodes",
"description": "…"
}
]
}
}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.
{
"jsonrpc": "2.0",
"id": 10,
"method": "tools/call",
"params": {
"name": "list_mcu_types",
"arguments": {}
}
}{
"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"
}
]
}
}Lists the supported link protocols for multi-node worlds.
{
"jsonrpc": "2.0",
"id": 11,
"method": "tools/call",
"params": {
"name": "list_link_types",
"arguments": {}
}
}{
"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"
}
]
}
}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.
{
"jsonrpc": "2.0",
"id": 12,
"method": "tools/call",
"params": {
"name": "list_svds",
"arguments": {}
}
}{
"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"
}
]
}
}List the active simulated nodes and their links. Use this first to learn node_ids and link_ids.
{
"jsonrpc": "2.0",
"id": 20,
"method": "tools/call",
"params": {
"name": "list_nodes",
"arguments": {}
}
}{
"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)"
}
]
}
}The full nodes+links graph (who is wired to whom), as structured data.
{
"jsonrpc": "2.0",
"id": 21,
"method": "tools/call",
"params": {
"name": "get_topology",
"arguments": {}
}
}{
"jsonrpc": "2.0",
"id": 21,
"result": {
"content": [
{
"type": "text",
"text": "{\"nodes\":[…],\"links\":[…]}"
}
]
}
}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.
{
"jsonrpc": "2.0",
"id": 22,
"method": "tools/call",
"params": {
"name": "upload_firmware",
"arguments": {
"node_id": 0,
"firmware_hex": "7f454c46010101000000000000000000"
}
}
}{
"jsonrpc": "2.0",
"id": 22,
"result": {
"content": [
{
"type": "text",
"text": "uploaded firmware for node 0 (16 bytes); call start_node to run it"
}
]
}
}Begin/resume execution (QMP cont) and release the clock-barrier hold — the unfreeze primitive for a node launched frozen.
{
"jsonrpc": "2.0",
"id": 23,
"method": "tools/call",
"params": {
"name": "start_node",
"arguments": {
"node_id": 0
}
}
}{
"jsonrpc": "2.0",
"id": 23,
"result": {
"content": [
{
"type": "text",
"text": "node 0 started"
}
]
}
}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.
{
"jsonrpc": "2.0",
"id": 24,
"method": "tools/call",
"params": {
"name": "run_until",
"arguments": {
"console": {
"node_id": 0,
"contains": "Running LPUART example"
}
}
}
}{
"jsonrpc": "2.0",
"id": 24,
"result": {
"content": [
{
"type": "text",
"text": "condition met at vtime 1200000 ns"
}
]
}
}Read and drain a node's buffered guest->host console output since the last read.
{
"jsonrpc": "2.0",
"id": 25,
"method": "tools/call",
"params": {
"name": "read_console",
"arguments": {
"node_id": 0
}
}
}{
"jsonrpc": "2.0",
"id": 25,
"result": {
"content": [
{
"type": "text",
"text": "Running LPUART example\r\nInput character to echo...\r\n>"
}
]
}
}The current virtual time in nanoseconds (latest observed vtime). Virtual time is deterministic and independent of wall-clock.
{
"jsonrpc": "2.0",
"id": 26,
"method": "tools/call",
"params": {
"name": "get_time",
"arguments": {}
}
}{
"jsonrpc": "2.0",
"id": 26,
"result": {
"content": [
{
"type": "text",
"text": "vtime_ns: 1200000"
}
]
}
}Read a node's CPU registers via QMP (a deterministic snapshot at the current vtime).
{
"jsonrpc": "2.0",
"id": 30,
"method": "tools/call",
"params": {
"name": "read_registers",
"arguments": {
"node_id": 0
}
}
}{
"jsonrpc": "2.0",
"id": 30,
"result": {
"content": [
{
"type": "text",
"text": "R00=00000000 R01=4006b000 … PC=00000410 …"
}
]
}
}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.
{
"jsonrpc": "2.0",
"id": 31,
"method": "tools/call",
"params": {
"name": "read_memory",
"arguments": {
"node_id": 0,
"address": "0xE000ED28",
"length": 4
}
}
}{
"jsonrpc": "2.0",
"id": 31,
"result": {
"content": [
{
"type": "text",
"text": "0xe000ed28: 00 00 00 00"
}
]
}
}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.
{
"jsonrpc": "2.0",
"id": 32,
"method": "tools/call",
"params": {
"name": "read_fault",
"arguments": {
"node_id": 0
}
}
}{
"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."
}
]
}
}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.
{
"jsonrpc": "2.0",
"id": 51,
"method": "tools/call",
"params": {
"name": "set_breakpoint",
"arguments": {
"node_id": 0,
"addr": 134218272
}
}
}{
"jsonrpc": "2.0",
"id": 51,
"result": {
"content": [
{
"type": "text",
"text": "node 0 breakpoint armed at 0x8000420"
}
]
}
}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.
{
"jsonrpc": "2.0",
"id": 52,
"method": "tools/call",
"params": {
"name": "run_until",
"arguments": {
"breakpoint": {
"node_id": 0
}
}
}
}{
"jsonrpc": "2.0",
"id": 52,
"result": {
"content": [
{
"type": "text",
"text": "condition met"
}
]
}
}Release a breakpoint pause so the node resumes executing under virtual time — the counterpart to set_breakpoint + run_until {breakpoint}.
{
"jsonrpc": "2.0",
"id": 53,
"method": "tools/call",
"params": {
"name": "resume",
"arguments": {
"node_id": 0
}
}
}{
"jsonrpc": "2.0",
"id": 53,
"result": {
"content": [
{
"type": "text",
"text": "node 0 resumed"
}
]
}
}Write host->guest bytes to a node's console (the passthrough boundary; no vtime, routed immediately).
{
"jsonrpc": "2.0",
"id": 40,
"method": "tools/call",
"params": {
"name": "write_console",
"arguments": {
"node_id": 0,
"data": "S"
}
}
}{
"jsonrpc": "2.0",
"id": 40,
"result": {
"content": [
{
"type": "text",
"text": "wrote 1 byte to node 0 console"
}
]
}
}Frames seen crossing inter-node links (the wiretap tap): src node, vtime, seq, raw bytes (hex). Optional filters: link_id, since_vtime.
{
"jsonrpc": "2.0",
"id": 41,
"method": "tools/call",
"params": {
"name": "read_link",
"arguments": {
"link_id": 0
}
}
}{
"jsonrpc": "2.0",
"id": 41,
"result": {
"content": [
{
"type": "text",
"text": "[{\"src_node_id\":0,\"delivery_vtime_ns\":1200000,\"sequence_number\":1,\"payload\":\"52756e6e696e67\"}]"
}
]
}
}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.
{
"jsonrpc": "2.0",
"id": 42,
"method": "tools/call",
"params": {
"name": "inject_frame",
"arguments": {
"link_id": 0,
"data": "deadbeef",
"vtime_ns": 2000000
}
}
}{
"jsonrpc": "2.0",
"id": 42,
"result": {
"content": [
{
"type": "text",
"text": "injected 4 bytes on link 0 at vtime 2000000 ns"
}
]
}
}The global vtime-ordered event log (link routes + lifecycle). Optional filters: kind, since_vtime, node (src), link_id.
{
"jsonrpc": "2.0",
"id": 43,
"method": "tools/call",
"params": {
"name": "read_events",
"arguments": {
"kind": "pdes_route",
"since_vtime": 0
}
}
}{
"jsonrpc": "2.0",
"id": 43,
"result": {
"content": [
{
"type": "text",
"text": "[{\"kind\":\"pdes_route\",\"vtime_ns\":1200000,\"summary\":\"node 0 -> link 0 (7 bytes)\"}]"
}
]
}
}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.
{
"jsonrpc": "2.0",
"id": 50,
"method": "tools/call",
"params": {
"name": "reset_node",
"arguments": {
"node_id": 0
}
}
}{
"jsonrpc": "2.0",
"id": 50,
"result": {
"content": [
{
"type": "text",
"text": "node 0 reset; rejoined at vtime 0"
}
]
}
}Agents are our customers — requesting a part is a prioritization signal. Recorded to the operator's feedback sink.
{
"jsonrpc": "2.0",
"id": 60,
"method": "tools/call",
"params": {
"name": "request_mcu",
"arguments": {
"vendor": "Nordic",
"part": "nRF52840",
"why": "BLE + Thread firmware bring-up"
}
}
}{
"jsonrpc": "2.0",
"id": 60,
"result": {
"content": [
{
"type": "text",
"text": "Recorded request_mcu — thank you; agent requests prioritize our roadmap. (…)"
}
]
}
}Request an inter-node protocol you need.
{
"jsonrpc": "2.0",
"id": 61,
"method": "tools/call",
"params": {
"name": "request_link",
"arguments": {
"protocol": "SPI",
"why": "sensor bus bring-up"
}
}
}{
"jsonrpc": "2.0",
"id": 61,
"result": {
"content": [
{
"type": "text",
"text": "Recorded request_link — thank you …"
}
]
}
}Request a CMSIS-SVD register map to be vendored.
{
"jsonrpc": "2.0",
"id": 62,
"method": "tools/call",
"params": {
"name": "request_svd",
"arguments": {
"vendor": "Nordic",
"part": "nRF52840"
}
}
}{
"jsonrpc": "2.0",
"id": 62,
"result": {
"content": [
{
"type": "text",
"text": "Recorded request_svd — thank you …"
}
]
}
}Report a bug or fidelity gap, optionally with a deterministic repro (world + firmware + seed).
{
"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"
}
}
}{
"jsonrpc": "2.0",
"id": 63,
"result": {
"content": [
{
"type": "text",
"text": "Recorded report_issue — thank you …"
}
]
}
}Suggest a capability you wish existed.
{
"jsonrpc": "2.0",
"id": 64,
"method": "tools/call",
"params": {
"name": "suggest_feature",
"arguments": {
"summary": "set_breakpoint(symbol) + HardFault auto-trap"
}
}
}{
"jsonrpc": "2.0",
"id": 64,
"result": {
"content": [
{
"type": "text",
"text": "Recorded suggest_feature — thank you …"
}
]
}
}Ask a support question; returns guidance + doc pointers (keyword-routed).
{
"jsonrpc": "2.0",
"id": 65,
"method": "tools/call",
"params": {
"name": "ask_support",
"arguments": {
"question": "How do I read console output from a node?"
}
}
}{
"jsonrpc": "2.0",
"id": 65,
"result": {
"content": [
{
"type": "text",
"text": "Console: use write_console to send input and read_console to drain output …"
}
]
}
}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.
{
"jsonrpc": "2.0",
"id": 51,
"method": "tools/call",
"params": {
"name": "inject_frame",
"arguments": {
"link_id": 1,
"data": "34120000",
"vtime_ns": 5000000
}
}
}{
"jsonrpc": "2.0",
"id": 51,
"result": {
"content": [
{
"type": "text",
"text": "injected at vtime 5000000 (link 1, seq 0, 4 bytes)"
}
]
}
}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).
{
"jsonrpc": "2.0",
"id": 52,
"method": "tools/call",
"params": {
"name": "read_link",
"arguments": {
"link_id": 1
}
}
}{
"jsonrpc": "2.0",
"id": 52,
"result": {
"content": [
{
"type": "text",
"text": "[vtime 5000000 link 1 src node 4294967294 seq 0] 4 bytes: 34120000"
}
]
}
}Same world + same ELFs + same injection timeline ⇒ bit-identical run. Every injected frame traverses the simulation's quantum barrier with an explicit virtual timestamp and canonical tie-breaking, so "flaky" firmware bugs stop being flaky here: a race condition that reproduces once reproduces forever, until you fix it — and the fix is provable by rerunning the identical timeline.