bench-mcp

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.

Language
Python 3.11+, mypy --strict
Tests
140 passing, about 20 seconds
Protocol
MCP Python SDK, ten typed tools, stdio
Instruments
SCPI over TCP; a VISA path, written but untested
Run against
The bundled simulated bench, not real hardware yet
License
MIT
bench-mcp demo ▸ set_voltage(instrument="psu", channel=1, volts=12) ✗ REFUSED: voltage 12 V exceeds the signed-off limit of 5.5 V

One 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.

1A person signs off the limits

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.

  • The device under test tightens the channels that power it. Channel 1 is rated for 12 V, but the board on it is a 5 V part, so its effective limit is 5.5 V.
  • The parser fails closed. An unknown key is an error, because a typo like 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


              
    Real output of 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.

    2Typed tools, and no raw SCPI

    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.

    • The only values that reach an instrument are numbers the interlock approved and channel numbers the schema bounded.
    • "5V" fails the schema. 3300, millivolts meant as volts, passes the schema and fails the limit in stage 3.
    • An argument a tool does not take, such as 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.
    • A call refused at this layer never reaches a tool, but it still leaves a 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

        Sent by the repo's own test rig: a real MCP client, the real tools, simulated instruments on TCP. Messages are the server's replies, cut to the field and the reason, and each call left a 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.

        3One pure function decides

        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.

        • A voltage must be a finite number from 0 up to the limit. A current limit must be positive and within its limit.
        • The output cannot come on until a current limit has been chosen since the last reset.
        • After a reconnect or a reset, every request is refused until the channel has been driven to a known safe state: output off, 0 V, 0 A.

        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

        Calls and replies are verbatim from the hero terminal's run, in order. The 6.0 V damage line is the simulated board's rating, and the 5.5 V line is the signed-off limit from stage 1. Requests past the scale, like 12 V and 3300 V, are pinned to its right edge.

        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.

        4Ramp, never jump

        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.

        • Channel 1 allows 0.5 V steps at 5 V/s, so 3.3 V takes 7 steps of 0.471 V with about 94 ms after each.
        • The server holds the instrument's lock from the decision to the last step, so two concurrent calls cannot both be approved against the same starting state.
        • After every step it reads the error queue and the setting back. An instrument that does not obey drops the connection and is reset to a safe state on next use.

        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

        Every 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.

        5Catch a change behind its back

        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.

        • On a mismatch, the channel goes back to the safe state and the request is refused with an explanation.
        • The safe state also clears the current limit, so nothing switches on again until the agent chooses one.
        • A *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

          The front-panel change is made up: the test rig wrote 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.

          6Stop mid-ramp, and stay stopped

          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.

          • It does not wait for the instrument's main lock. It takes each connection's short I/O lock, and every ramp step re-checks the latch under that same lock, so a ramp in progress stops at its next step.
          • A supply it cannot reach is reported as failed with “Switch it off by hand”, not as done.

          A switch-on to 5 V, stopped 300 ms in

          From the audit log of a run through the test rig at the example's real 5 V/s, with the stop sent 300 ms into the switch-on as the repo's own test does. The faded steps are the rest of the plan the interlock had approved; none of them was sent. Times are milliseconds from the output_on call.

          The same requests, with nothing in between

          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.

          Through bench-mcp

          
                    

          Raw SCPI, no bench-mcp

          
                    

          The property test is tested

          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.

          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.

            Waits are minimums

            3.3 V on, planned660 ms
            3.3 V on, took670 ms
            3.3 to 5 V, planned340 ms
            3.3 to 5 V, took342 ms

            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.

            Every line, in order

            203 SCPI lines241 audit entries

            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”.

            140 tests, judged from the wire

            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.

              Weighted toward what an agent might send

              • Adversarial: instrument names like 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.
              • Config: thirty ways a bench file is rejected, and the packaged example kept identical to the one in examples/.
              • Interlock: unit tests for each rule, the Hypothesis state machine, a ramp property, a pinned case Hypothesis found (a 1e-9 V change on a live output sent with no wait), and the six mutations above.
              • End to end: 60 concurrent tool calls, the stop mid-ramp, changes behind the server's back, a dropped connection, the wrong instrument at an address, the rate limit on an injected clock. After each, the board must still be OK.
              • Simulator and CLI: SCPI forms, units, error queue overflow, each damage model, and the demo run end to end over stdio with nothing on stderr.

              CI runs ruff, mypy --strict and the suite on Python 3.11 and 3.13, then bench-mcp check and bench-mcp demo.

              Deliberately missing

              A hardware interlock
              It is software between an agent and an instrument. It cannot stop a person at the front panel or firmware that ignores commands. A real board still needs fuses and the supply's own OVP and OCP.
              Real instruments, proven
              It has only run against the simulator. Real supplies differ in their SCPI, so a driver needs checking against each one. The VISA transport is untested.
              More than one server per bench
              Locks are in-process. A second server on the same instrument would be caught only by the readback check on its next change.
              Scope and meter settings
              Timebases, triggers and ranges are not exposed. The agent can only take readings.

              Run it

              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.