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.
Install as a plugin
The quickest way. The plugins in embeddedci-com/benchpod-mcp run this server through uvx and ask for the pod's address, the board's I/O voltage and an optional API key. All you need is a BenchPod reachable over your network, USB or embeddedci.com; the plugins install everything else on first start (see the FAQ).
No terminal and no claude command needed.
1
Open Settings → Plugins, click Add and add a marketplace. Choose Add from a repository and enter embeddedci-com/benchpod-mcp.

2
Benchpod shows up under From marketplaces you added.

The plugin works in the Code tab. Ask the agent to connect to your pod ("connect to the BenchPod at 192.168.1.213", or "find my BenchPod on the network") and it sets the board's I/O voltage before it uses the pins.
Set up your client by hand
The configs below run the server with uvx, so they need uv on your PATH (how to install it). The Claude app extension brings its own.
claude mcp add benchpod \ -e BENCHPOD_CONNECTION=192.168.1.213 -e BENCHPOD_LA_VOLTAGE=3.3 \ -- uvx embeddedci-mcp
BENCHPOD_LA_VOLTAGE must match the target's I/O voltage (1.8 for a 1V8 board). 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.
A pod in the cloud
"env": {
"BENCHPOD_CONNECTION": "embeddedci:my-bench",
"BENCHPOD_API_KEY": "eci_…",
"BENCHPOD_LA_VOLTAGE": "3.3"
}Or skip the API key: after benchpod login the server reuses that session for cloud_list_devices and connect("embeddedci:<name>"). BENCHPOD_API_KEY wins when set, and inside GitHub Actions the SDK uses OIDC instead. cloud_list_devices lists the pods on your account and which are online without connecting to one; pass a result's connection to connect.
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.
The saved connection
connect picks its connection from its connection argument, then --connection, then BENCHPOD_CONNECTION, then the default connection the CLI saved (benchpod set-connection or benchpod discover --save, in ~/.config/benchpod-cli/config.json), then mDNS discovery of the one pod on the LAN. So a pod you set up with the CLI needs no MCP configuration. Needs embeddedci 2.10 or newer.
The wiring profile
The server resolves the wiring the same way the SDK does: a local wiring file (BENCHPOD_WIRING) first, then the profile stored on embeddedci.com for this pod (the Wiring tab of the pod's workspace), then the defaults. The stored profile is used for an embeddedci:<name> connection, and over the LAN when you are signed in (BENCHPOD_API_KEY or benchpod login) and the pod is on your account; over USB it is not. If it cannot be read, the defaults apply and the connection still works. BENCHPOD_WIRING_SOURCE=local skips it. The status result says which profile is in force in its wiring_source field, and set_wiring replaces it for the session.
Options
| Flag | Environment | Default | Purpose |
|---|---|---|---|
--connection | BENCHPOD_CONNECTION | — | host[:port], serial device, usb, discover, or embeddedci:<name> |
--la-voltage | BENCHPOD_LA_VOLTAGE | — | LA I/O voltage (1.8 or 3.3) applied on connect |
— | BENCHPOD_API_KEY | — | cloud pods and the waveform library (without it, the benchpod login session) |
— | BENCHPOD_API_BASE | https://www.embeddedci.com | another embeddedci server |
--lease-wait | — | 30 | cloud: seconds connect waits for a busy pod |
--idle-timeout | — | 600 | cloud: release the lease after this many idle seconds (0 = never) |
--timeout | — | 30 | per-command pod timeout in seconds |
--transport | — | stdio | stdio or http |
--host / --port | — | 127.0.0.1 / 8000 | HTTP bind address |
--auth-token | EMBEDDEDCI_MCP_TOKEN | — | require a bearer token (mandatory off loopback) |
--allowed-host | — | — | Host header(s) to accept on a network bind |
--allow-unauthenticated | — | off | serve a network address without a token (isolated networks only) |
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 192.168.1.213 --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 serialized.
Tools
Parameters in bold are required; the others are optional and show their default when they have one (no default means from the wiring profile, keep the current setting, or not used). Units are volts, amps, seconds and hertz. LA channel arguments take a number (1-14) or a name from the wiring profile. Each tool's input schema carries the full description, ranges and enums.
Connection
| Tool | Purpose | Parameters |
|---|---|---|
connect | Open a connection (closing any previous one) and report status and capabilities. | connection, la_voltage, lease_wait=30 |
disconnect | Close UART/CAN sessions and the connection, releasing a cloud lease. | none |
status | Firmware, capabilities, LA voltage, open sessions and warnings (works when not connected). | none |
set_la_voltage | Select the LA I/O voltage to match the DUT (1.8 V needs a rev3 pod). | voltage |
Cloud (embeddedci.com)
| Tool | Purpose | Parameters |
|---|---|---|
cloud_list_devices | The pods on your embeddedci.com organization and whether each is online, without connecting. | none |
Wiring profile
| Tool | Purpose | Parameters |
|---|---|---|
wiring | Which DUT signal is on which LA channel, plus the power rail, UART baud and SWD target. | none |
set_wiring | Replace the wiring profile for this connection, or store it on embeddedci.com. | profile, save=false |
Power
| Tool | Purpose | Parameters |
|---|---|---|
power_on | Switch the target's power rail on (optionally scheduled pod-side). | efuse, delay |
power_off | Switch the target's power rail off. | efuse, delay |
power_status | Both eFuse rails: on/off, tripped, bus voltage and current, and the pod's own draw. | none |
reset_target | Drive the DUT's reset line from the pod's reset pin. | action=pulse, pulse=0.1 |
Power profiles
| Tool | Purpose | Parameters |
|---|---|---|
measure_power | Sample supply current and voltage for a window: average, min, peak, energy and charge. | duration, efuse, rate_hz=500, points=0 |
power_profile_start | Start sampling the rail in the background while other tools run. | efuse, rate_hz=500, max_duration=60 |
power_profile_stop | Stop the running profile and return its statistics (and a trace). | points=0 |
Flash
| Tool | Purpose | Parameters |
|---|---|---|
flash | Program the DUT over SWD through the pod's CMSIS-DAP probe: a local file (OpenOCD runs next to the server), or a build uploaded to embeddedci.com (build id or latest, repo, ref). On an embeddedci:<pod> connection embeddedci.com runs OpenOCD for an uploaded build; on LAN/USB it is downloaded and flashed next to the server. The result's artifact says which build was flashed. | swclk, swdio, target, file, nreset, load_address, target_power, verify=true, reset=true, connect_under_reset, extra_configs, extra_args, timeout=300, connect_attempts=5, build, repo, ref, artifact, server_side |
SPI flash and SPI devices
| Tool | Purpose | Parameters |
|---|---|---|
spi_flash_info | Read the JEDEC ID and size of the SPI NOR flash on the SPI pins. | sck, mosi, miso, cs, hz=1000000, mode=0 |
spi_flash_program | Erase, write and verify an image in the SPI NOR flash. | file, addr=0, erase=true, verify=true, hold_reset=false, sck, mosi, miso, cs, hz=6000000, mode=0 |
spi_flash_read | Read flash bytes to a file, or up to 4096 bytes as hex. | addr, length, file, sck, mosi, miso, cs, hz=6000000, mode=0 |
spi_transfer | One full-duplex SPI transaction with any device on the SPI pins. | tx_hex, sck, mosi, miso, cs, hz=1000000, mode=0 |
UART
| Tool | Purpose | Parameters |
|---|---|---|
capture_uart | Record the DUT's UART for a window, or until a regex matches. | duration, rx, tx, baud, until_regex |
power_cycle_and_capture | Power-cycle the target and capture UART across the boot. | rx, tx, efuse, delay=1, duration=4, baud, until_regex, off_settle=0.3 |
uart_open | Start buffering the DUT's UART in the background. | rx, tx, baud |
uart_write | Send text to the DUT through the open UART session. | text, line_ending=lf |
uart_read | Return output received since the last read, optionally waiting for a match. | until_regex, timeout=2 |
uart_close | Stop the background UART session. | none |
Emulated I2C sensor
| Tool | Purpose | Parameters |
|---|---|---|
enable_i2c_sensor | Make the pod act as an I2C sensor for the DUT to read (engage pull-ups first): sensor is bmp280, bme280, sht4x or mpu6050. | sda, scl, address, temperature_c, pressure_pa, sensor="bmp280", values |
set_i2c_sensor | Change what the emulated sensor reports: temperature_c, pressure_pa, or any reading in values (e.g. {"humidity_pct": 55}). | temperature_c, pressure_pa, values |
i2c_sensor_types | The models the pod emulates, with their addresses and readings (key, unit, range, default). | none |
disable_i2c_sensor | Disarm the emulated sensor. | none |
i2c_sensor_status | Sensor state and bus activity counters: did the DUT talk to it? | none |
i2c_sensor_regs | Read the emulated sensor's register image. | start=0, length=256 |
i2c_sensor_capture | Capture and decode the sensor's I2C bus into a transaction trace. | samples=4096, sample_rate_hz=500000, address, register |
Emulated GPS receiver
| Tool | Purpose | Parameters |
|---|---|---|
enable_gps | Make the pod play a GPS module: NMEA sentences on tx for the DUT's UART RX, on a second UART so uart_open keeps the console (gateware v48). | tx, baud=9600, rate_hz=1, sentences, latitude_deg, longitude_deg, altitude_m, speed_kmh, course_deg, satellites, hdop, fix, utc |
set_gps | Change the fix: the position moves along course_deg at speed_kmh, fix=0 drops the lock. | latitude_deg, longitude_deg, altitude_m, speed_kmh, course_deg, satellites, hdop, fix, utc |
disable_gps | Stop the GPS receiver and release its pin. | none |
gps_status | The session (tx, baud, rate, fixes sent, overruns) and the fix it prints. | none |
Pull resistors
| Tool | Purpose | Parameters |
|---|---|---|
set_pull | Engage or release the bias resistors: LA1-LA6 pull up, LA7/LA8 pull down. | las, enabled |
pull_status | State, direction and value of the bias resistor on LA1-LA8. | none |
GPIO on the LA pins
| Tool | Purpose | Parameters |
|---|---|---|
la_pins | What owns each of the 14 LA channels, with GPIO mode, level and bias resistor. | none |
gpio_mode | Claim LA channels as GPIO (input, output or open drain). | la, mode=output, level |
gpio_write | Set the level of GPIO output and open-drain channels. | la, level |
gpio_read | The live level of LA channels (all 14 when la is omitted). | la |
gpio_wait | Wait until an LA channel reads a level (reached: false on timeout). | la, level=1, timeout=5 |
gpio_pulse | FPGA-timed pulses on an LA channel: width 4 us to 65.535 ms, count up to 65535. | la, width, count=1 |
gpio_release | Return channels to high-Z, watched by captures. | la |
Analog
| Tool | Purpose | Parameters |
|---|---|---|
analog_path | Set every analog mux and relay for a named path in one step. | path |
dac_output | Route a DAC output and drive a calibrated DC voltage on it. | path, volts |
current_out | Hold a current on the 4-20 mA output (J9), in amps, or read its range. | current |
adc_read | One calibrated reading: front SMA, DAC loopbacks or the 4-20 mA input (J8). | source=ext |
calibration | The stored calibration of the 4-20 mA input and the fit it uses. | none |
calibrate | Measure and store the 4-20 mA input's offset, or clear it. | source=current_in, clear=false |
Capture and decode
| Tool | Purpose | Parameters |
|---|---|---|
capture_adc | Capture the ADC: calibrated statistics, dominant frequency and a min/max envelope. | samples=4096, sample_rate_hz, source, points=200, trigger_la, trigger_edge=rising, trigger_timeout=10 |
capture_la | Capture all 14 LA channels: levels, edge count, first edge and frequency per channel. | samples=4096, sample_rate_hz, stop_dac_after, trigger_la, trigger_edge=rising, trigger_timeout=10 |
capture_correlated | ADC and LA from one hardware trigger, on one timebase. | adc_samples=4096, adc_sample_rate_hz, la_samples=4096, la_sample_rate_hz, stop_dac_after, points=200, trigger_la, trigger_edge=rising, trigger_timeout=10, source |
decode_la | Decode I2C, UART or SPI from the last LA capture (or a new one). | protocol, sda, scl, rx, baud, sclk, mosi, miso, cs, mode=0, capture=last, samples=16384, sample_rate_hz=1000000, max_items=200 |
la_timing | Edge timestamps, pulse widths, frequency, duty cycle and channel-to-channel delay from the last capture. | la, edge=rising, to_la, to_edge=rising, after=0, max_edges=100 |
DAC
| Tool | Purpose | Parameters |
|---|---|---|
generate | Drive a sine, square or sawtooth on a DAC output (volts, or amps on current_out). | waveform, freq_hz, amplitude, offset, dac_path=5v, duration, sample_rate_hz, on_capture=false, route=true |
dac_stop | Stop any DAC output: generator, replay or control loop. | none |
replay | Loop your own samples, or the last ADC capture, out of the DAC. | volts, from_last_capture=false, dac_path=5v, mapping=faithful, sample_rate_hz, fault, on_capture=false, route=true, switch_image=true |
list_waveforms | The organization's cloud waveform library. | none |
replay_waveform | Loop a cloud-library waveform out of the DAC. | waveform_id, dac_path, mapping=faithful, sample_rate_hz, window_start=0, window_len=0, target_samples=0, fault, on_capture=false, switch_image=true |
save_capture_as_recording | Save the last ADC capture to the cloud waveform library. | name, full_scale_v |
Control loop
| Tool | Purpose | Parameters |
|---|---|---|
control_loop | Run a tabulated transfer function in the FPGA (for example a solar panel I-V curve). | curve, voc_code, sharpness=4, points=256, k=8192, vmin=0, vmax=65535, tick_div=64, source, input_code=0, step=0, input_map, switch_image=true |
loop_input | Re-target the running loop's input without re-arming. | input_code, source, step |
loop_probe | The running loop's live operating point. | none |
fpga_image | Switch the FPGA to the loop or deep_replay gateware image (~2-3 s). | image |
CAN
| Tool | Purpose | Parameters |
|---|---|---|
can_open | Bring up the pod's CAN interface (normal, loopback or listen-only). | bitrate=500000, mode=normal, term=false |
can_write | Queue one classic CAN frame. | can_id, data, ext=false, rtr=false |
can_read | Read received frames, or wait for one id. | timeout=0, can_id, max_frames=32 |
can_respond | Answer a CAN id from the pod firmware (ECU simulation), or clear all rules. | match_id, reply_id, reply_data, match_ext=false, reply_ext=false, clear=false |
can_status | Link state: mode, bitrate, error counters, bus-off. | none |
can_close | Clear responder rules and stop CAN. | none |
Other
| Tool | Purpose | Parameters |
|---|---|---|
la_step | Step pulses for step/dir motor drivers: delay is half the step period (4 us to 65.535 ms), steps up to 65535. | la, steps, delay, dir_la, direction=0 |
command | Raw firmware JSON command, for anything no tool covers. | request |
Two resources are also exposed: benchpod://wiring (the connected pod'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 pod's wiring profile that 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-14, 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: uart_proxy_start: la voltage not set; call set_la_voltage first with the DUT's I/O voltage (1.8 or 3.3). A completed operation with a negative outcome is a normal result:flashreturnsok: falsewith its logs, a UART capturematched: false. - Refusals. When the pod or the server refuses a command, the error names the kind and ends with a one-line hint:
PodLockedError(the pod's LAN policy keeps the command for the cloud or USB),PodLeasedError(a cloud job holds the pod, with who and for how long),PodBusyError(a capture or upload is running),PermissionDeniedError(the credential lacks the right, for example an API key without thebenchpod:adminscope) andTransportTimeout(the pod did not answer). A dropped link is aConnectionClosedError.statusalso warns when a cloud job holds the pod or the LAN policy is locked. - Capabilities.
connectandstatusreport what the pod can do as flags, such asla_pins,capture_trigger,power_profile,nrst_pin,can,calibrate,current_out,pod_currentandanalog(false on the digital-only board), plus its policy and cloud settings (dac_limits,flash_kb,ota_sig,sig_policy,lan_policy,tunnel_max_tier,lease_state,cloud_ca,cloud_proxy), so an agent can check before it calls a tool. - Agent-sized captures.
capture_adcreturns calibrated statistics, the dominant frequency and a min/max envelope;capture_lareturns per-channel levels, edges and frequencies. The last captures stay in the session fordecode_la,replayand saving to the waveform library. - Sessions.
uart_openbuffers the console in the background (open it beforepower_on, thenuart_read/uart_write);can_openkeeps 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 pod
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(temperature_c=22.5, pressure_pa=101000) # SDA/SCL from the wiring profile
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?FAQ
Do I need to install uv or Python?
Not for the plugins. The Claude Code and Codex plugins (1.0.8 and later) use your uv if you have one, and otherwise download a private copy on first start, which also fetches Python. Setting up a client by hand (claude mcp add, codex mcp add, a JSON or TOML config) runs uvx, so that needs uv on your PATH. Alternatively, pip install embeddedci-mcp (Python 3.10+) and run embeddedci-mcp as the command.
brew install uv # or: curl -LsSf https://astral.sh/uv/install.sh | sh
Codex says “no BenchPod MCP tools are exposed”
The plugin is installed but its server didn't start. The first start downloads uv and the server, so give it a minute, then quit Codex completely and reopen it. Plugin versions before 1.0.8 need uv installed already: update or reinstall the BenchPod plugin in Codex, or install uv (above).
“command not found: claude” or “codex”
The Claude and Codex desktop apps don't add a command-line tool. Use the app steps above, which need no terminal, or install the CLI from the tabs in its section.
What does flashing need?
The flash tool drives OpenOCD on the machine running the server, and reads the firmware file from that machine too. It needs the cmsis_dap_tcp backend, which only exists in builds newer than 0.12.0; the stock packages lack it. Nothing else needs OpenOCD.
# Homebrew's stable open-ocd is 0.12.0, so build it from HEAD brew install --HEAD open-ocd






