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-one 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, 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. No secrets are written to disk; credentials are resolved at startup from a local store.
How it works
Every call, 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 one byte has changed it names the exact sequence number. It also catches deleted receipts and checks the signed handoffs. 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. The first two live bundles, snapshots from two cameras, verify as conformant in pyref. 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. Under the one tool contract there are two transports, AXIS VAPIX over HTTP CGI and ONVIF SOAP, picked per camera. 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 server signs with one key, not one per agent. I posted it to Hacker News on August 20, 2026. Since then it has gained config drift tools, optional certificate pinning (live on one camera), event checks during commissioning, rate limits, and 476 tests that run offline.
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