BenchPod MCP server

embeddedci-mcp is an MCP server that lets an AI agent drive a real hardware-in-the-loop bench: flash a build, power-cycle the target, watch and type into its UART, emulate the sensor it expects, probe its I2C and CAN traffic, and drive and measure analog signals. It is a thin layer over the BenchPod SDK, so what an agent finds interactively can become a pytest test.

Requirements

  • Python 3.10+ and uv (for uvx), or pip install embeddedci-mcp.
  • A BenchPod reachable over the network, USB, or embeddedci.com.
  • For flash: OpenOCD with the cmsis_dap_tcp backend (newer than 0.12.0, e.g. brew install --HEAD open-ocd) on the machine running the server. The firmware file is read from that machine too.

Set up your client

Claude Code

claude mcp add benchpod \
  -e BENCHPOD_CONNECTION=192.168.1.213 -e BENCHPOD_LA_VOLTAGE=3.3 \
  -- uvx embeddedci-mcp

Claude Desktop / Cursor

Add the server to claude_desktop_config.json (Claude Desktop) or .cursor/mcp.json (Cursor):

{
  "mcpServers": {
    "benchpod": {
      "command": "uvx",
      "args": ["embeddedci-mcp"],
      "env": {
        "BENCHPOD_CONNECTION": "192.168.1.213",
        "BENCHPOD_LA_VOLTAGE": "3.3"
      }
    }
  }
}

BENCHPOD_LA_VOLTAGE must match the target's I/O voltage (1.8 for a 1V8 board) — set it once here; the pod refuses flashing, UART, logic capture, pull resistors and sensor emulation until it is set. Without it the agent is told to call set_la_voltage first.

Codex

The Codex CLI keeps its MCP servers in ~/.codex/config.toml. Add it with the CLI:

codex mcp add benchpod \
  --env BENCHPOD_CONNECTION=192.168.1.213 --env BENCHPOD_LA_VOLTAGE=3.3 \
  -- uvx embeddedci-mcp

…or write the entry yourself — codex mcp list shows what is configured:

# ~/.codex/config.toml
[mcp_servers.benchpod]
command = "uvx"
args = ["embeddedci-mcp"]
env = { BENCHPOD_CONNECTION = "192.168.1.213", BENCHPOD_LA_VOLTAGE = "3.3" }

A pod in the cloud

"env": {
  "BENCHPOD_CONNECTION": "embeddedci:my-bench",
  "BENCHPOD_API_KEY": "eci_…",
  "BENCHPOD_LA_VOLTAGE": "3.3"
}

A cloud pod is shared, so connect takes an exclusive lease. It is released by disconnect, or after the idle timeout (10 minutes by default) — the next tool call reconnects transparently, so an idle chat never blocks CI on that pod.

Options

FlagEnvironmentDefaultPurpose
--connectionBENCHPOD_CONNECTIONhost[:port], serial device, usb, discover, or embeddedci:<device>
--la-voltageBENCHPOD_LA_VOLTAGELA I/O voltage (1.8 or 3.3) applied on connect
BENCHPOD_API_KEYcloud pods and the waveform library
--lease-wait30cloud: seconds connect waits for a busy pod
--idle-timeout600cloud: release the lease after this many idle seconds (0 = never)
--transportstdiostdio or http
--host / --port127.0.0.1 / 8000HTTP bind address
--auth-tokenEMBEDDEDCI_MCP_TOKENrequire a bearer token (mandatory off loopback)
--allowed-hostHost header(s) to accept on a network bind

Serving a bench over HTTP

Run the server next to the pod, then point clients at it:

export EMBEDDEDCI_MCP_TOKEN=$(openssl rand -hex 32)
embeddedci-mcp --transport http --host 0.0.0.0 --connection usb --la-voltage 3.3
claude mcp add --transport http benchpod http://bench-host:8000/mcp \
  --header "Authorization: Bearer $EMBEDDEDCI_MCP_TOKEN"

The server drives real hardware, so it refuses a non-loopback bind without a token. All HTTP clients share one pod connection and their tool calls are serialised.

Tools

GroupTools
Connectionconnect, disconnect, status, set_la_voltage
Wiringwiring, set_wiring
Powerpower_on, power_off, power_status, reset_target
Power profilesmeasure_power, power_profile_start, power_profile_stop
Flashflash
UARTcapture_uart, power_cycle_and_capture, uart_open, uart_write, uart_read, uart_close
Emulated I2C sensorenable_i2c_sensor, set_i2c_sensor, disable_i2c_sensor, i2c_sensor_status, i2c_sensor_regs, i2c_sensor_capture
Pull resistorsset_pull, pull_status
GPIO on the LA pinsla_pins, gpio_mode, gpio_write, gpio_read, gpio_wait, gpio_pulse, gpio_release
Analoganalog_path, dac_output, adc_read
Capture + decodecapture_adc, capture_la, capture_correlated, decode_la, la_timing
DACgenerate, dac_stop, replay, list_waveforms, replay_waveform, save_capture_as_recording
Control loopcontrol_loop, loop_input, loop_probe, fpga_image
CANcan_open, can_write, can_read, can_respond, can_status, can_close
Otherla_step, command

Two resources are also exposed: benchpod://wiring (the connected device's effective wiring profile — which role or named signal is on each LA channel) and benchpod://help (the server instructions). The wiring tool returns the same profile and set_wiring changes it; it is the bench description the web app's Wiring tab edits and the SDK reads as bp.wiring.

How it behaves

  • Instructions. The server sends usage instructions when a client connects — session start, typical flows, units — so the agent knows to connect and set the LA voltage before anything else.
  • Typed, structured results. Every tool has an input schema with enums and ranges (paths, sources, LA channels 1-12, eFuse 1/2) and an output schema. Units are volts, seconds and hertz.
  • Errors. A tool that cannot do what was asked fails with an error that names the cause, such as FirmwareError: la voltage not set. A completed operation with a negative outcome is a normal result: flash returns ok: false with its logs, a UART capture matched: false.
  • Agent-sized captures. capture_adc returns calibrated statistics, the dominant frequency and a min/max envelope; capture_la returns per-channel levels, edges and frequencies. The last captures stay in the session for decode_la, replay and saving to the waveform library.
  • Sessions. uart_open buffers the console in the background (open it before power_on, then uart_read / uart_write); can_open keeps a CAN bus open across calls.
  • Non-blocking. Long operations such as a flash run in the background, send progress, and never interleave commands with another call.
  • Annotations. Read-only tools are marked so clients can auto-approve them; tools that power, flash or drive voltages are marked destructive.

Example agent flow

connect()                                  # BENCHPOD_CONNECTION + BENCHPOD_LA_VOLTAGE
wiring()                                   # which DUT signal is on which LA channel on this bench
flash(swclk=11, swdio=12, nreset=true, target="target/stm32f4x.cfg",
      file="build/app.elf", target_power=1)
power_cycle_and_capture(rx=5, tx=4, delay=1.0, duration=5.0, until_regex="APP_OK")
set_pull(las=[1, 2], enabled=true)
enable_i2c_sensor(sda=2, scl=1, temperature_c=22.5, pressure_pa=101000)
power_cycle_and_capture(rx=5, tx=4, delay=1.0, duration=5.0)
i2c_sensor_capture(address=0x76, register=0xD0)   # did the firmware read the chip id?