embeddedci-mcp
An MCP server that lets an AI agent drive an EmbeddedCI BenchPod — a hardware-in-the-loop tester wired to a real board. Ask Claude (or any MCP client) to flash a build, power-cycle the target, watch its UART, pretend to be the sensor it expects, probe its I2C/CAN traffic or drive and measure analog signals, and the pod does it.
It is a thin layer over the embeddedci SDK: every tool
maps to SDK calls, so anything an agent discovers interactively can become a pytest test.
Requirements
- Python 3.10+ and uv (for
uvx), orpip. - A BenchPod reachable over the network or the embeddedci.com cloud. (A USB connection works for status, LA voltage and power only: the STM32 pod's USB console has no JSON mode.)
- For
flash: OpenOCD with thecmsis_dap_tcpbackend (newer than 0.12.0 — e.g.brew install --HEAD open-ocd, or the xPack build) on the machine running the server. The firmwarefileis 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
BENCHPOD_LA_VOLTAGE is the board's I/O voltage, configured once here — use 1.8 for a 1V8
board. Without it the agent is told to call set_la_voltage before touching the LA bank.
Claude Desktop / Cursor
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"
}
}
}
}
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
Use the device name and an API key — the cloud transport is included:
"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 (waiting up to --lease-wait
seconds if another run holds it). The lease is released by disconnect, or after
--idle-timeout seconds without a tool call — the next call reconnects transparently, so an idle
chat never blocks CI on that pod.
Options
| Flag | Environment | Default | |
|---|---|---|---|
--connection |
BENCHPOD_CONNECTION |
— | host[:port], serial device, usb, discover, or embeddedci:<device> |
--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 |
| — | BENCHPOD_API_BASE |
https://www.embeddedci.com |
another embeddedci server |
--lease-wait |
— | 30 |
cloud: seconds to wait for a busy pod |
--idle-timeout |
— | 600 |
cloud: release the lease after this many idle seconds (0 = never) |
--timeout |
— | 30 |
per-command device timeout |
--transport |
— | stdio |
stdio or http |
--host / --port |
— | 127.0.0.1 / 8000 |
HTTP bind address |
--auth-token |
EMBEDDEDCI_MCP_TOKEN |
— | require Authorization: Bearer <token> (mandatory off loopback) |
--allowed-host |
— | — | Host header(s) to accept on a network bind (DNS-rebinding protection) |
--allow-unauthenticated |
— | off | serve a network address without a token (isolated networks only) |
Serving a bench over HTTP
Run the server on the machine next to the pod, and 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. It holds one pod connection shared by all HTTP clients, and serialises their tool calls.
Tools
| Group | Tools |
|---|---|
| Connection | connect, disconnect, status, set_la_voltage |
| Wiring profile | wiring, set_wiring |
| Power | power_on, power_off, power_status, reset_target, measure_power, power_profile_start, power_profile_stop |
| Flash | flash |
| UART | capture_uart, power_cycle_and_capture, uart_open, uart_write, uart_read, uart_close |
| Emulated I2C sensor | enable_i2c_sensor, set_i2c_sensor, disable_i2c_sensor, i2c_sensor_status, i2c_sensor_regs, i2c_sensor_capture |
| Pins + GPIO | la_pins, gpio_mode, gpio_write, gpio_read, gpio_wait, gpio_pulse, gpio_release |
| Pull resistors | set_pull, pull_status |
| Analog | analog_path, dac_output, adc_read |
| Capture + decode | capture_adc, capture_la, capture_correlated, decode_la, la_timing |
| DAC | generate, dac_stop, replay, list_waveforms, replay_waveform, save_capture_as_recording |
| Control loop | control_loop, loop_input, loop_probe, fpga_image |
| CAN | can_open, can_write, can_read, can_respond, can_status, can_close |
| Other | la_step, command (raw firmware escape hatch) |
Resources: benchpod://wiring (the connected bench's own wiring profile, then LA channels, bias
resistors and analog paths) and benchpod://help (the server instructions).
How it behaves
- Instructions. The server sends usage instructions at initialisation — session start, typical
flows, units, error contract — so the agent knows to
connectandset_la_voltagebefore 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; results come back as structured content. Units are volts, seconds and hertz.
- Errors. A tool that cannot do what was asked fails with an MCP tool error whose message names
the cause, e.g.
FirmwareError: la voltage not setorNotConnectedError: …. A completed operation with a negative outcome is a normal result:flashreturnsok: falsewith its logs, a UART capturematched: false. - Wiring profile.
wiringis the bench's map of DUT signal → LA channel, plus the target-power rail, the UART baud and the SWD target. Omitted channel / baud / rail / SWD arguments come from it, and channel arguments also accept its names (trigger_la: "READY",rx: "uart_rx"), so an agent that readwiringonce can callflash(),uart_open()orpower_on()with no pin numbers at all.set_wiringreplaces it for the connection, or stores it on embeddedci.com withsave: true. - 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, sodecode_la,replay(from_last_capture=true)andsave_capture_as_recordingdon't capture again. - Triggered captures.
capture_adc,capture_laandcapture_correlatedtaketrigger_la(+trigger_edgerising/falling/high/low andtrigger_timeout) so sampling starts on an event — t = 0 is the trigger — and the summary echoes it astrigger: "LA9 rising". A condition that never happens fails withTriggerTimeout: …. - Pins and GPIO. Each LA channel has one owner at a time: none, GPIO, a UART proxy, SWD, the
emulated sensor or a step train.
la_pinsshows the table,gpio_mode/gpio_write/gpio_read/gpio_wait/gpio_pulsedrive and watch channels,gpio_releasefrees them. A second claim fails withPinConflictError: pin conflict: LA5 is in use by uart_rx; …naming the owner and how to free it — so the agent releases GPIO before opening a UART session on that channel. Captures observe all 12 channels whatever owns them. - Power profiles.
measure_power(duration)reports average, minimum and peak current, voltage, energy and charge. Every sample is timestamped and the integrals run over those timestamps, so energy is integrated rather than estimated.rate_hz(100-500, default 500) tracks the request to ~200 Hz and then flattens near 365 Hz — the result reports the rate actually delivered inrate_hzand the sensor's configured rate inadc_rate_hz. Optional downsampled trace (points).power_profile_start/power_profile_stopbracket other tool calls; the running profile lives on the session (statusreports it) and is dropped ondisconnect. - Sessions.
uart_openbuffers the DUT's console in the background (open it beforepower_on, thenuart_read/uart_write);can_openkeeps a CAN bus open across calls. - Gateware images. The pod's FPGA runs either the
loopimage (control_loop) or thedeep_replayimage (replays longer than 2048 samples).control_loop,replayandreplay_waveformswitch automatically (~3 s) and report it asswitched_image;switch_image: falsefails instead. A switch resets the FPGA — a running DAC output, UART session or I2C sensor emulation stops — and the server instructions tell the agent to start those after it.fpga_imageswitches explicitly. - Non-blocking. Tools run on worker threads under one device lock: a 5-minute flash doesn't freeze the server, sends progress notifications, and concurrent calls can't interleave commands.
- Annotations. Read-only tools (
status,power_status,adc_read, …) are marked so clients can auto-approve them; tools that power, flash or drive voltages are marked destructive.
Example
Connect to the bench, flash
build/app.elfto the STM32F4 (SWCLK on LA11, SWDIO on LA12, reset wired), then power-cycle it and tell me whether it reachesAPP_OKon the UART (DUT TX on LA5, RX on LA4).
connect() # BENCHPOD_CONNECTION + BENCHPOD_LA_VOLTAGE
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")
With a wiring profile stored for the bench, the same run needs no pin numbers:
connect()
wiring() # SWCLK on LA11, DUT TX on LA5, rail eFuse 1, …
flash(file="build/app.elf")
power_cycle_and_capture(delay=1.0, duration=5.0, until_regex="APP_OK")
measure_power(duration=2.0, points=100) # what the firmware draws once it is up
Stability
2.x freezes the tool names, input schemas and annotations (tests/tools_surface.json; CI fails on
any unreviewed change): tools and optional parameters may be added, nothing is renamed or removed
within a major version. See CHANGELOG.md for migrating from 0.1.
Publishing to the MCP Registry
server.json describes this package for the MCP Registry.
After the PyPI release (which is what makes uvx embeddedci-mcp work):
mcp-publisher login github
mcp-publisher publish
The registry verifies PyPI ownership through the mcp-name comment at the top of this README.
Development
pip install -e "../embeddedci[dev]" -e ".[dev]"
pytest
Release files for embeddedci-mcp 2.0.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| embeddedci_mcp-2.0.0.tar.gz | 70.5 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| embeddedci_mcp-2.0.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 116.8 kB
Release files / embeddedci_mcp-2.0.0.tar.gz
| Download URL | embeddedci_mcp-2.0.0.tar.gz |
|---|---|
| Size | 70.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
8aecf3f6df017f8bab3c547c9bba3fd7317c7b6df8166a40558053e800e0b927
|
|
BLAKE2b-256 checksum How to use checksums |
137ef2c4814180bd61c89edfb4d3c0f47c2a2fa8f7ce85e225b39e4a69f313f9
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 12, 2026.
Transparency logRelease files / embeddedci_mcp-2.0.0-py3-none-any.whl
| Download URL | embeddedci_mcp-2.0.0-py3-none-any.whl |
|---|---|
| Size | 46.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
30c6a220b31d8a765e3735354df894ba4f8d16a738269b352b266a75c587fc19
|
|
BLAKE2b-256 checksum How to use checksums |
96f6700cd6b2885132cdfb33e5c85d727332f3a63b302b74fea485a458264df0
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 12, 2026.
Transparency log