The problem in the field
Most of what I learned about video evidence comes down to one question: can you prove where it came from and that nobody touched it? For twenty years that question was about footage. Now agents are reaching for the cameras themselves. An agent that can pull a snapshot or swing a PTZ head is an operator, and the record of what an agent did is usually a log line inside the same system that did it. Edit the log and nothing notices.
Why the usual answers fail
ONVIF is the industry standard, and it is a swamp of SOAP, profiles, and vendor quirks. The quick fix is a thin wrapper that hands a model the raw camera API and puts the policy in the prompt. That leaves two holes. The agent can do anything the credential can do. And the check that stopped it lives nowhere. A gate that fires without leaving a record can't prove it ever fired.
What I built
An MCP server over stdio. The tools are list_cameras, clock_check, get_snapshot, ptz_move, ptz_preset, get_receipts, commission_plan / apply / verify, and config_baseline / drift / remediate. Policy is per agent and fails closed: a tool allowlist, a camera allowlist, PTZ step and zoom limits, per-minute rate limits, and config-write grants with a hard denylist. An agent the policy doesn't name gets nothing. The server never writes a camera password anywhere; it reads them at startup from a local credential store. Its signing keys are files on the same machine.
How it works
Every call the policy rules on, allowed or denied, appends a receipt to a JSONL file. Each receipt carries the hash of the one before it and an ed25519 signature. Frames are content-hashed, so a snapshot can be tied to the receipt that produced it. Running with --verify walks the chain, and if any value in a receipt has changed it names the exact sequence number. It catches a receipt pulled from the middle of the chain and checks the signed handoffs. It has a blind spot at the end: receipts cut off after the last signed handoff disappear without a trace. This is receipt 223 from the repo's own chain, trimmed: an agent asked to swing a camera 90° when its policy allows 30°.
{
"seq": 223,
"ts": "2026-09-18T21:40:36.626Z",
"principal": {"role": "agent", "id": "claude-main"},
"action": {"tool": "ptz_move",
"params": {"camera": "q6358", "pan": 90}},
"decision": "deny",
"detail": "step exceeds policy maxStep 30°",
"prev": "a2155062…bb68b",
"sig": "4WpvGtYq…R77Bw=="
}
Snapshot and preset calls also write receipts in the Agent Action Receipts wire format. Since October 12 that is AAR v0.2.3: the signed attempt is written to disk before the command goes to the camera, and if that write fails, the call is refused. All 22 live bundles written since then, snapshots from my three cameras, verify as conformant in pyref v0.2.5. Receipts written between August 15 and October 12 stay in that week's pre-release format, which the released verifiers reject. The main chain uses AAR's vocabulary but is not wire-conformant. Since October 15, no receipt in either format says a reading was independently sensed. A camera reporting back on a command it was just given is the camera vouching for itself, so the strongest thing a receipt says is that the device acknowledged it. Under the one tool contract there are two transports, AXIS VAPIX over HTTP CGI and ONVIF SOAP, picked per camera for device info, snapshots and PTZ moves. Presets, the clock check, commissioning and the config tools still need VAPIX. The SOAP is hand-authored. I claim no ONVIF conformance.
Where it stands
Version 0.1.0, experimental, tested against the three AXIS cameras on my own bench. Two things I measured rather than read: on AXIS OS 12.9.57 the admin account works for ONVIF over digest auth, while older 12.x firmware needs a separate ONVIF account. RelativeMove pan maps to degrees divided by 360 exactly; tilt and zoom are approximate. No non-AXIS hardware has been tried. The main chain and the handoffs are signed with one key for every agent. The AAR bundles use a key per agent and role, but one process holds them all. The config drift tools were already in when I posted it to Hacker News on August 20, 2026. Since then it has gained optional certificate pinning (live on one camera), event checks during commissioning, rate limits, a camera clock check, and 705 tests that run offline. As of October 17 the signed commissioning handoff also records what the camera's clock said, a reference frame with its hash, and where the PTZ head was pointing. When the same camera has an earlier signed handoff on file, a verify run reports what changed since then: the head position, the location note and the clock offset. It doesn't compare the frames; it names the earlier one for a person to look at. There is no "moved" flag; a person decides what a difference means. The first of these runs, on my Q6358, found its clock 9 minutes 40 seconds fast against my machine, with NTP turned on but not synced since the last restart. A second run 13 seconds later found the head in the same position and the clock offset within the margin the two readings allow. The handoff says in writing that none of this is part of pass/fail, and that none of it proves the time was right or where the camera physically is.
What's next
Per-agent signing keys and a signed policy file, clip export, non-AXIS ONVIF hardware, and alignment with the MCP 2026-07-28 stateless revision. A side-ship came out of this work: vapix-openapi, an MIT-licensed OpenAPI description of the fifteen VAPIX paths I needed.
- github.com/oneshot2001/onvif-mcp
- github.com/oneshot2001/vapix-openapi
- camspec, the commissioning tools that ship inside it