Local IntentLedger and MCP stdio proxy: propose a tool action, approve or deny in an append-only hash-chained ledger, then let an identical tools/call reach one upstream MCP server.
This is not a SaaS. The ledger path stays on disk. dry-run and approve record decisions only; they do not execute tools. The MCP proxy is what executes, and only after approve.
The drawing is those three steps. The same steps against a real echo server:
./examples/mcp/demo.sh
First tools/call returns ACTGATE_PENDING and does not reach upstream.
actgate approve writes the ledger and still does not. The identical call
then runs once. CI runs that script on Python 3.12.
pip install actgate
Dev:
pip install -e .[dev]
actgate init
actgate propose --tool shell.exec --args '{"cmd":"ls"}' --blast-tags fs.read
actgate pending
actgate dry-run <intent_id>
actgate approve <intent_id>
actgate verify
actgate list
Point your MCP client at ActGate instead of the upstream server:
actgate init
actgate mcp --upstream python -m some_mcp_server
Use the same --root (or the same working directory) for init, approve/deny, and mcp, so they share one ledger.
Flow:
- Client
tools/listis forwarded to upstream. Onlytools/callis gated; other methods are forwarded. - First
tools/callfor a tool+args writes a propose event and returnsACTGATE_PENDING intent_id=...(upstream is not called). - Human:
actgate approve <intent_id>(oractgate deny <intent_id>). - Identical subsequent
tools/call(same tool and args) runs upstream once and appends anexecuteevent. A third call returns already-executed. - Denied intents never hit upstream.
Bare verify checks hash-chain integrity only. Set ACTGATE_SEAL_KEY for
optional HMAC seals, or pass verify --require-seal.
Typical MCP approval loop:
- Client hits
actgate mcp --upstream ...and getsACTGATE_PENDING. - Human:
actgate pending(oractgate pending --watch) to see undecided proposes. actgate approve <intent_id>oractgate deny <intent_id>.- Client retries the same
tools/call; proxy executes once.
pending is the propose-without-decision queue. --watch polls (default 1s) and
prints newly pending intents as JSON until Ctrl-C (clean exit 0).
| Code | Meaning |
|---|---|
| 0 | ok (propose, approve, verify clean, show/list/pending) |
| 1 | deny recorded, or verify found a broken chain / bad seal |
| 2 | setup error (missing ledger, bad path, invalid args) |
{
"tool": "shell.exec",
"args": {"cmd": "ls"},
"args_hash": null,
"blast_tags": ["fs.read"],
"requested_mode": "execute",
"created_at": "2026-09-06T00:00:00+00:00"
}- Not a hosted approval product
- Not a policy DSL
- No network calls in the ledger core path (the MCP proxy talks to a local upstream process)
pip install -e .[dev]
pytest
