Six mutations, six caught
Each line is the first violation the property test reported against that mutated interlock, re-run on this machine with the suite's settings: derandomized, 300 examples of up to 50 steps.
An MCP server that lets an AI agent drive lab instruments over SCPI: power supplies, multimeters and oscilloscopes. Every change passes an interlock built from limits a person signed off, and the agent has no tool that reaches around it.
An agent that brings a board up rail by rail is useful until one VOLT 12 meant for the 12 V rail lands on the 5 V one. Asking it to be careful does not stop that, and neither does a model that is usually right. So the limits live in a file the agent never sees, and a pure function checks every tool call against them before a byte reaches the instrument.
Plays the six stages below in order, about 70 seconds. Scroll or press Esc to stop.
--strictOne real run of bench-mcp demo, split into three tabs. The simulated supply, meter and scope run on TCP, the server runs as a real stdio subprocess, and the agent is a fixed list of tool calls, so no model is involved. Output is verbatim, without its colours.
The limits come from bench.toml: who signed it off and when, each supply channel's maximum voltage, current, ramp step and slew, and the device under test. The server reads it once, at start-up. No tool can read it back or change it.
max_voltge would otherwise mean no limit at all.bench-mcp check prints the effective limits and the file's SHA-256. The same hash goes into the audit log when the server starts.It cannot know whether 5.5 V is right for the board. It enforces what a person signed off, and the file is where that judgement lives.
The example bench, then six broken copies
$ bench-mcp check examples/bench.toml
bench-mcp check. Each broken copy is examples/bench.toml with the one edit shown; every one exited with status 2 before anything could start. The test suite rejects thirty such files.The agent gets ten tools and none of them takes a command string. A voltage is a JSON number of volts, a channel is an integer from 1 to 16, an instrument name must match ^[a-z][a-z0-9_-]*$, and a measurement is picked from a fixed list.
"5V" fails the schema. 3300, millivolts meant as volts, passes the schema and fails the limit in stage 3.limit_override, is refused by name, and every tool schema but emergency_stop's forbids extra properties. The stop always runs, whatever comes with it.call and a refused line in the audit log, with the reason.This is also the defence against a prompt injection in a document the agent read: there is no field where text it was told to send could travel to the wire.
Hostile calls to a live output at 1 V
0 changes sent to the supply, and every refusal in the audit log
call and a refused entry in the audit log. “Changes sent” counts lines the simulated supply received that set something, not channel selects or queries.interlock.decide(state, request, limits, latched) takes what the server believes a channel is set to, the request and the signed-off limits. It returns a refusal, or the exact steps that carry the request out. It does no I/O and has no clock and no randomness.
Because it is pure, the property tests drive it with 400 sequences of up to 60 requests on every run, garbage included, and check every step it emits.
The demo's requests against channel 1's limits
Call 1 of 7
ClearedAbove this line a request is only checked, and nothing it carries reaches an instrument. Below it, approved steps go out one SCPI line at a time, and each is read back.
On a live output, a change larger than ramp_step becomes several steps no larger than that, each followed by a wait that keeps the average slew under max_slew. Switching on into a setpoint above one step starts at 0 V and ramps up.
The simulated board is damaged by an instant step up of more than 1 V while powered. Sent as raw SCPI to a fresh simulated supply, CURR 0.25, VOLT 3.3 and OUTP ON left it reporting INRUSH DAMAGE.
What the supply was told, and when
VOLT and OUTP line and its readback come from the audit log of the hero terminal's run, with milliseconds counted from each decision. Each wait is a minimum, so a busy machine can only make a ramp slower.Before every change, the server asks the supply what it holds and compares that with what it last told it. A front-panel change, another program or a *RST shows up here.
*RST sent behind its back was caught the same way, with the same refusal.With verify_readback = false none of this runs, and the server could believe a current limit is set after the supply has gone back to its default. The example file leaves it on.
Someone turns the knob to 2.4 V Illustrative input
bench-mcp last told it
The supply holds
INST:NSEL 1;VOLT 2.4 straight to the simulated supply, as a person or another program might. Every tool call, reply and line sent to the supply is real, from the same run.emergency_stop is never refused and never rate limited. It latches first, then switches every supply output off. After it, only “output off” reaches an instrument, until a person restarts the server. No tool clears it.
A switch-on to 5 V, stopped 300 ms in
output_on call.The demo ends by sending the agent's intent straight to a fresh simulated supply as raw SCPI. The simulator damages its board above 6.0 V, over 0.4 A for more than 0.2 s, or on a jump of more than 1 V while powered, so the tests can check that the board survived, not just that the server said no. Both cards are lines from the hero terminal's run, verbatim; the ellipsis marks calls left out.
A Hypothesis state machine sends arbitrary request sequences, garbage included, while a model of the instrument follows every step the interlock emits. To show that test can fail, the suite breaks the interlock six ways and requires a violation each time.
Each line is the first violation the property test reported against that mutated interlock, re-run on this machine with the suite's settings: derandomized, 300 examples of up to 50 steps.
Planned time is the sum of the interlock's waits, voltage change over 5 V/s. Took is decision to result in the audit log of the hero's run. A ramp can run slow, never fast.
The hero's run wrote 241 entries to audit.jsonl, 203 of them SCPI lines in either direction, each with a sequence number. One test checks that the lines logged as sent match what the simulated supply received, exactly.
That includes the "5V" call, which the schema refused before it reached a tool: one call line and one refused line, “invalid arguments: volts: Input should be a valid number”.
Counted with pytest -q: 140 passed in about 20 seconds on Python 3.12. Safety is judged from what the simulated supply actually received, replayed line by line, not from what the server reports.
psu; OUTP ON, CRLF, NUL, 10,000 characters and Unicode lookalikes; voltages like "5000mV", NaN, ±Infinity and 1e308; injection inside measurement names; arguments a tool does not take. None may put a byte of its own on the wire, and every refusal must reach the audit log.examples/.CI runs ruff, mypy --strict and the suite on Python 3.11 and 3.13, then bench-mcp check and bench-mcp demo.
git clone https://github.com/ahmed-hashim-pro/bench-mcp cd bench-mcp python3 -m venv .venv && . .venv/bin/activate pip install -e '.[dev]' bench-mcp demo # the session above bench-mcp check examples/bench.toml # effective limits pytest -q # 140 tests # A simulated bench for a real agent: bench-mcp sim --config examples/bench.toml claude mcp add bench -- "$PWD/.venv/bin/bench-mcp" \ serve --config "$PWD/examples/bench.toml"
Python 3.11 or later. No API key and no hardware: sim runs a supply, a meter and a scope on 127.0.0.1, ports 5025 to 5027. For real instruments, point the addresses in a copy of bench.toml at their LAN ports.