hwcontract
A zero-dependency MCP server that judges hardware against a contract. Coding
agents (Claude Code, Codex, opencode) write firmware that's correct on paper but
wrong on the wire โ a WS2812 pulse 180ns short, an ESC bit out of spec, a boot log
that silently panics. This closes the loop: it captures what the hardware actually
did and returns pass / marginal / fail the agent can iterate on.
marginal is the valuable verdict โ in-spec but low-headroom, the bug that works on
your bench and fails on a cold board in the field.
Demo โ a real capture, no hardware needed
python3 demo/ws2812b_neopixel.py downloads a real 24-LED NeoPixel capture
(recorded off hardware by the sigrok project, 24 MHz), extracts the data line, and
judges it against two contracts:
measured on the real WS2812B signal (300000 samples @24MHz):
T0H 333 ns T1H 833 ns T1L 417 ns T0L 917 ns RESET 992250 ns
=== generic WS2812 contract -> FAIL ===
T1L 600 417 FAIL 183ns short (typ 600) # real WS2812B low times are shorter
T0L 800 917 marginal
T1H 700 833 marginal
=== matching WS2812B contract -> PASS ===
(all pass)
Same real signal: it fails the generic WS2812 contract (correctly โ a WS2812B
isn't a WS2812) and passes the matching WS2812B one. That's the tool doing its
job: measure the real signal, hold it to a spec, and make contracts chip-specific.
How it fits together
observers (capture) judge (this repo)
โโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโ
logic analyzer โ pulse widths โโ
serial port โ log text โโโโโโผโโบ contract ร observation โโบ pass/marginal/fail
โ (judge.py)
judge.py โ the pure judge (timing + serial). No hardware, no framework, cached.
sigrok_adapter.py โ logic-analyzer capture โ pulse-width observations (WS2812/DShot).
serial_adapter.py โ serial log capture (or replay a saved log).
server.py โ the MCP server (stdio JSON-RPC, stdlib only).
*.contract.yaml โ what "correct" looks like. Human-editable. Also serve as regression tests.
Install
pip install hwcontract
pip install "hwcontract[serial]"
pip install "hwcontract[untrusted]"
pip install "hwcontract[all]"
Also needs sigrok-cli on PATH for live logic-analyzer capture (check_ws2812 /
check_dshot). Judge-only tools (judge_contract, judge_serial) need nothing extra.
Wire it into an agent
One stanza per client (not auto-discovered โ add it once). After pip install, the
hwcontract command is on your PATH.
Claude Code
claude mcp add hwcontract -- hwcontract
Codex CLI โ ~/.codex/config.toml
[mcp_servers.hwcontract]
command = "hwcontract"
opencode / Cursor / Gemini / any stdio MCP client
{ "mcpServers": { "hwcontract": { "command": "hwcontract" } } }
Transport is stdio by default (local, no auth surface). For remote-only clients
(e.g. ChatGPT connectors), run hwcontract --http 8791 and expose it via a tunnel
with HWCONTRACT_TOKEN set for bearer auth.
If the client can't find hwcontract (PATH issues)
GUI apps and some agents don't inherit your shell PATH, so a bare hwcontract
can fail with "command not found". Two robust fixes:
- Use the absolute path:
which hwcontract โ put that full path in command.
- Or invoke via Python (no PATH lookup for the script):
command: "python3",
args: ["-m", "hwcontract.server"] โ works from any directory once installed.
Contract paths: pass an absolute contract_path, or set HWCONTRACT_ROOT
to your contracts folder โ relative paths resolve against it (default: the process's
working directory, which the client controls and may not be your project). Paths
outside the root are rejected. Bundled examples install with the package under
hwcontract/examples/.
| Tool | Hardware? | What it does |
|---|
judge_contract | no | Judge given observations against a timing contract. Replay / testing. |
judge_serial | no | Judge a given log string against a serial contract's expect/forbid. |
check_ws2812 | yes | Capture a live WS2812 line and judge it, one call. |
check_dshot | yes | Same, for a DShot600 ESC signal. |
capture_ws2812 | yes | Just capture โ observations (no judging). |
check_serial | yes | Read a serial port for N seconds and judge the log. |
Contracts
Timing (ws2812.contract.yaml, dshot.contract.yaml) โ pulse widths in ns:
contract: ws2812
headroom_pct: 20
edges:
- {name: T0H, min: 200, typ: 350, max: 500}
Serial (boot.contract.yaml) โ Python regex:
contract: boot
kind: serial
expect: ["IMU init OK", "boot v\\d+"]
forbid: ["panic", "Guru Meditation", "\\bnan\\b"]
Add a protocol = drop a new YAML. No code change for another timing signal.
Kill switch
Instantly disable every hardware-touching tool (captures) while leaving the pure
judge tools working:
export HWCONTRACT_SAFE=1
touch /home/tsd/projects/hardware/KILLSWITCH
Security
Every tool argument is treated as hostile (the caller is an LLM that can be prompt-
injected): contract paths are confined to the server dir (override HWCONTRACT_ROOT),
driver/channel/port are charset-validated, samples/seconds/samplerate are
clamped, sigrok-cli runs with a timeout, YAML is safe_load. Do not expose this
server over the network without adding authentication.
Self-tests (no hardware, run from anywhere)
hwcontract --selftest
python3 -m hwcontract.judge --demo
python3 -m hwcontract.sigrok_adapter --demo
python3 -m hwcontract.serial_adapter --demo