Guide ·
What a dry-run of an agent's MCP writes shows
okgate is an MCP proxy: it sits between an agent and the MCP servers it calls. In dry-run mode it passes the agent’s reads through to the real servers and fakes its writes. The agent gets back a plausible success, nothing reaches the server, and the call is written to an audit log. After the run, okgate report says what the run would have done and okgate diff lists every write it faked.
Everything below is the real output of one run on 7 October 2026, made with agentguard 0.1.6 (okgate’s name until 8 October 2026; the same commands now print okgate and write okgate.yaml and .okgate/) and the two fixtures that ship with it: a fake CRM with ten tools and a scripted agent that misbehaves on purpose.
1. init writes a dry-run policy
okgate init finds the project’s MCP config (.mcp.json, .cursor/mcp.json, .vscode/mcp.json, mcp.json or .gemini/settings.json), connects to each server it names and lists its tools, and writes okgate.yaml with mode: dry-run. It then points the config at the proxy and keeps a backup; okgate init --undo puts the old one back.
$ npx @agentwares/agentguard init
found .mcp.json (Claude Code (project .mcp.json)) → 1 server: crm
probing 1 upstream…
crm: 10 tools
wrote agentguard.yaml (mode: dry-run, 1 upstream, 10 tools: 4 read, 4 write, 1 spend, 1 unknown)
rewrote .mcp.json → crm now go through `npx @agentwares/agentguard proxy` (backup: .mcp.json.agentguard-backup)Every tool is classified as a read, a write or a spend: first by any pattern you list under classify, then by the tool’s MCP annotations (readOnlyHint, destructiveHint), then by the verb in its name. A tool none of those classify counts as a write. This is what init wrote for the demo CRM:
# Detected tools (10: 4 read, 4 write, 1 spend, 1 unknown): # crm_list_contacts read read annotation: readOnlyHint: true # crm_get_contact read read annotation: readOnlyHint: true # crm_search read read heuristic: verb "search" # crm_create_contact write create annotation: readOnlyHint: false # crm_update_contact write update annotation: readOnlyHint: false # crm_delete_contact write delete annotation: readOnlyHint: false, destructiveHint: true # crm_send_email write send heuristic: verb "send" # crm_charge_card spend spend heuristic: verb "charge" # crm_frobnicate unknown unknown none: no recognizable verb # crm_fail read read annotation: readOnlyHint: true # readOnlyHint annotations and verb heuristics classify most tools. List anything that is wrong here; # these patterns win over annotations. `*` matches anything. classify: read: [] write: ["crm_frobnicate"] # could not be classified — treated as writes; move to read: if they only read spend: ["crm_charge_card"] unknown: write # a tool nobody can classify counts as a write (alternatives: read | block)
2. The agent runs, and its writes are faked
The scripted agent lists contacts, reads one, creates one, updates one, deletes one, sends an email and charges a card $12. Then it repeats the same update, and then it creates contacts in a burst of up to 60. It made 54 calls: the two reads went to the CRM, 50 calls were faked (the charge among them), and two were refused.
Two limits still applied in dry-run, because caps and the loop breaker run in both modes. The third identical update was refused with LOOP_DETECTED, and the burst stopped at the 51st write, over the writes: 50 per-run cap init sets, with CAP_EXCEEDED. The agent receives each refusal as a tool result carrying a code, a cause and a fix.
3. okgate report: what the run would have done
run_dryrun_demo54 tool calls between 2026-10-07T05:24:00.526Z and 2026-10-07T05:24:00.627Z across crm.
It would have deleted 1 record, updated 3, created 44, sent 1 message.
Run agentguard diff for the record-by-record mutation diff. Flip mode: enforce when it looks right.
| # | when | code | tool | why |
|---|---|---|---|---|
| 10 | 2026-10-07T05:24:00.567Z | LOOP_DETECTED | crm_update_contact | "crm_update_contact" was called 3 times with the same arguments in the last 30 calls |
| 54 | 2026-10-07T05:24:00.627Z | CAP_EXCEEDED | crm_create_contact | writes cap for this run is 50; used 50, this call would make it 51 |
By class: write 51, read 2, spend 1By outcome: faked 50, ok 2, halted 1, blocked 1
| tool | class | calls |
|---|---|---|
crm_create_contact | write | 45 |
crm_update_contact | write | 4 |
crm_list_contacts | read | 1 |
crm_get_contact | read | 1 |
crm_delete_contact | write | 1 |
crm_send_email | write | 1 |
crm_charge_card | spend | 1 |
Audit chain: 54 entries, verified (~/demo/.agentguard/audit.jsonl).
The report names no dollar amount. crm_charge_card was classified as a spend from the verb in its name, but the policy init writes has no rule for reading an amount from that tool’s arguments, so the charge counted as $0 (default_usd: 0). One line of policy fixes that; it is in spend caps for the charges an agent’s tools make.
4. okgate diff: every faked write, with its arguments
One entry per faked call, in the order the agent made them, with the arguments it sent: +++ create, ~~~ update, --- delete, >>> send, $$$ spend. The bracket gives the call’s number in the audit log and its time.
$ okgate diff+++ CREATE via crm/crm_create_contact (name=Demo Person) [#3 2026-10-07T05:24:00.551Z] + { + "name": "Demo Person", + "email": "demo@example.com" + }~~~ UPDATE via crm/crm_update_contact (id=c_1) [#4 2026-10-07T05:24:00.555Z] ~ { ~ "id": "c_1", ~ "fields": { ~ "name": "Ada L." ~ } ~ }--- DELETE via crm/crm_delete_contact (id=c_2) [#5 2026-10-07T05:24:00.557Z] - { - "id": "c_2" - }>>> SEND via crm/crm_send_email (to=ada@example.com) [#6 2026-10-07T05:24:00.559Z] > { > "to": "ada@example.com", > "subject": "Hi", > "body": "Hello from the demo agent" > }$$$ SPEND via crm/crm_charge_card (customer_id=c_1) [#7 2026-10-07T05:24:00.561Z] $ { $ "customer_id": "c_1", $ "amount_cents": 1200, $ "currency": "usd" $ }~~~ UPDATE via crm/crm_update_contact (id=c_3) [#8 2026-10-07T05:24:00.563Z] ~ { ~ "id": "c_3", ~ "fields": { ~ "name": "Same Thing" ~ } ~ }~~~ UPDATE via crm/crm_update_contact (id=c_3) [#9 2026-10-07T05:24:00.565Z] ~ { ~ "id": "c_3", ~ "fields": { ~ "name": "Same Thing" ~ } ~ }
The other 43 entries
+++ CREATE via crm/crm_create_contact (name=Bulk 0) [#11 2026-10-07T05:24:00.568Z] + { + "name": "Bulk 0", + "email": "bulk0@example.com" + }+++ CREATE via crm/crm_create_contact (name=Bulk 1) [#12 2026-10-07T05:24:00.569Z] + { + "name": "Bulk 1", + "email": "bulk1@example.com" + }+++ CREATE via crm/crm_create_contact (name=Bulk 2) [#13 2026-10-07T05:24:00.571Z] + { + "name": "Bulk 2", + "email": "bulk2@example.com" + }+++ CREATE via crm/crm_create_contact (name=Bulk 3) [#14 2026-10-07T05:24:00.572Z] + { + "name": "Bulk 3", + "email": "bulk3@example.com" + }+++ CREATE via crm/crm_create_contact (name=Bulk 4) [#15 2026-10-07T05:24:00.574Z] + { + "name": "Bulk 4", + "email": "bulk4@example.com" + }+++ CREATE via crm/crm_create_contact (name=Bulk 5) [#16 2026-10-07T05:24:00.575Z] + { + "name": "Bulk 5", + "email": "bulk5@example.com" + }+++ CREATE via crm/crm_create_contact (name=Bulk 6) [#17 2026-10-07T05:24:00.576Z] + { + "name": "Bulk 6", + "email": "bulk6@example.com" + }+++ CREATE via crm/crm_create_contact (name=Bulk 7) [#18 2026-10-07T05:24:00.578Z] + { + "name": "Bulk 7", + "email": "bulk7@example.com" + }+++ CREATE via crm/crm_create_contact (name=Bulk 8) [#19 2026-10-07T05:24:00.580Z] + { + "name": "Bulk 8", + "email": "bulk8@example.com" + }+++ CREATE via crm/crm_create_contact (name=Bulk 9) [#20 2026-10-07T05:24:00.581Z] + { + "name": "Bulk 9", + "email": "bulk9@example.com" + }+++ CREATE via crm/crm_create_contact (name=Bulk 10) [#21 2026-10-07T05:24:00.583Z] + { + "name": "Bulk 10", + "email": "bulk10@example.com" + }+++ CREATE via crm/crm_create_contact (name=Bulk 11) [#22 2026-10-07T05:24:00.584Z] + { + "name": "Bulk 11", + "email": "bulk11@example.com" + }+++ CREATE via crm/crm_create_contact (name=Bulk 12) [#23 2026-10-07T05:24:00.585Z] + { + "name": "Bulk 12", + "email": "bulk12@example.com" + }+++ CREATE via crm/crm_create_contact (name=Bulk 13) [#24 2026-10-07T05:24:00.586Z] + { + "name": "Bulk 13", + "email": "bulk13@example.com" + }+++ CREATE via crm/crm_create_contact (name=Bulk 14) [#25 2026-10-07T05:24:00.587Z] + { + "name": "Bulk 14", + "email": "bulk14@example.com" + }+++ CREATE via crm/crm_create_contact (name=Bulk 15) [#26 2026-10-07T05:24:00.589Z] + { + "name": "Bulk 15", + "email": "bulk15@example.com" + }+++ CREATE via crm/crm_create_contact (name=Bulk 16) [#27 2026-10-07T05:24:00.590Z] + { + "name": "Bulk 16", + "email": "bulk16@example.com" + }+++ CREATE via crm/crm_create_contact (name=Bulk 17) [#28 2026-10-07T05:24:00.592Z] + { + "name": "Bulk 17", + "email": "bulk17@example.com" + }+++ CREATE via crm/crm_create_contact (name=Bulk 18) [#29 2026-10-07T05:24:00.593Z] + { + "name": "Bulk 18", + "email": "bulk18@example.com" + }+++ CREATE via crm/crm_create_contact (name=Bulk 19) [#30 2026-10-07T05:24:00.594Z] + { + "name": "Bulk 19", + "email": "bulk19@example.com" + }+++ CREATE via crm/crm_create_contact (name=Bulk 20) [#31 2026-10-07T05:24:00.595Z] + { + "name": "Bulk 20", + "email": "bulk20@example.com" + }+++ CREATE via crm/crm_create_contact (name=Bulk 21) [#32 2026-10-07T05:24:00.597Z] + { + "name": "Bulk 21", + "email": "bulk21@example.com" + }+++ CREATE via crm/crm_create_contact (name=Bulk 22) [#33 2026-10-07T05:24:00.599Z] + { + "name": "Bulk 22", + "email": "bulk22@example.com" + }+++ CREATE via crm/crm_create_contact (name=Bulk 23) [#34 2026-10-07T05:24:00.600Z] + { + "name": "Bulk 23", + "email": "bulk23@example.com" + }+++ CREATE via crm/crm_create_contact (name=Bulk 24) [#35 2026-10-07T05:24:00.601Z] + { + "name": "Bulk 24", + "email": "bulk24@example.com" + }+++ CREATE via crm/crm_create_contact (name=Bulk 25) [#36 2026-10-07T05:24:00.603Z] + { + "name": "Bulk 25", + "email": "bulk25@example.com" + }+++ CREATE via crm/crm_create_contact (name=Bulk 26) [#37 2026-10-07T05:24:00.605Z] + { + "name": "Bulk 26", + "email": "bulk26@example.com" + }+++ CREATE via crm/crm_create_contact (name=Bulk 27) [#38 2026-10-07T05:24:00.606Z] + { + "name": "Bulk 27", + "email": "bulk27@example.com" + }+++ CREATE via crm/crm_create_contact (name=Bulk 28) [#39 2026-10-07T05:24:00.607Z] + { + "name": "Bulk 28", + "email": "bulk28@example.com" + }+++ CREATE via crm/crm_create_contact (name=Bulk 29) [#40 2026-10-07T05:24:00.609Z] + { + "name": "Bulk 29", + "email": "bulk29@example.com" + }+++ CREATE via crm/crm_create_contact (name=Bulk 30) [#41 2026-10-07T05:24:00.610Z] + { + "name": "Bulk 30", + "email": "bulk30@example.com" + }+++ CREATE via crm/crm_create_contact (name=Bulk 31) [#42 2026-10-07T05:24:00.611Z] + { + "name": "Bulk 31", + "email": "bulk31@example.com" + }+++ CREATE via crm/crm_create_contact (name=Bulk 32) [#43 2026-10-07T05:24:00.613Z] + { + "name": "Bulk 32", + "email": "bulk32@example.com" + }+++ CREATE via crm/crm_create_contact (name=Bulk 33) [#44 2026-10-07T05:24:00.614Z] + { + "name": "Bulk 33", + "email": "bulk33@example.com" + }+++ CREATE via crm/crm_create_contact (name=Bulk 34) [#45 2026-10-07T05:24:00.615Z] + { + "name": "Bulk 34", + "email": "bulk34@example.com" + }+++ CREATE via crm/crm_create_contact (name=Bulk 35) [#46 2026-10-07T05:24:00.616Z] + { + "name": "Bulk 35", + "email": "bulk35@example.com" + }+++ CREATE via crm/crm_create_contact (name=Bulk 36) [#47 2026-10-07T05:24:00.617Z] + { + "name": "Bulk 36", + "email": "bulk36@example.com" + }+++ CREATE via crm/crm_create_contact (name=Bulk 37) [#48 2026-10-07T05:24:00.619Z] + { + "name": "Bulk 37", + "email": "bulk37@example.com" + }+++ CREATE via crm/crm_create_contact (name=Bulk 38) [#49 2026-10-07T05:24:00.620Z] + { + "name": "Bulk 38", + "email": "bulk38@example.com" + }+++ CREATE via crm/crm_create_contact (name=Bulk 39) [#50 2026-10-07T05:24:00.622Z] + { + "name": "Bulk 39", + "email": "bulk39@example.com" + }+++ CREATE via crm/crm_create_contact (name=Bulk 40) [#51 2026-10-07T05:24:00.623Z] + { + "name": "Bulk 40", + "email": "bulk40@example.com" + }+++ CREATE via crm/crm_create_contact (name=Bulk 41) [#52 2026-10-07T05:24:00.624Z] + { + "name": "Bulk 41", + "email": "bulk41@example.com" + }+++ CREATE via crm/crm_create_contact (name=Bulk 42) [#53 2026-10-07T05:24:00.626Z] + { + "name": "Bulk 42", + "email": "bulk42@example.com" + }
50 mutations would have run: 1 delete, 3 updates, 44 creates, 1 send, 0 executes.5. The log behind both
Each call is one JSON line in .okgate/audit.jsonl, chained to the one before it by a hash. okgate verify recomputes the chain and fails on the first entry that was edited, removed from the middle or moved:
$ agentguard verify
ok: 54 entries, chain intact, head 4cb1d13f099627b1… (~/demo/.agentguard/audit.jsonl)Entries cut from the end of the file leave a shorter chain that still verifies; record the head hash and the entry count somewhere else to catch that.
What a dry-run does not show
- Faked results are built from each tool’s output schema. An agent that creates a record and then updates it by the id it got back is working with an id that does not exist on the server.
- Approvals do not fire, because nothing would run: the delete above was faked, not held. In
mode: enforcethe same delete returnsAPPROVAL_REQUIREDuntil someone runsokgate approve <id>. - The proxy sees MCP tool calls. Token spend inside a model call is visible only through the SDK’s guarded
fetch.
When the diff looks right, set mode: enforce. Tools listed under dry_run.tools stay faked in enforce mode, so the dangerous ones can stay in dry-run while the rest go live. The live demo runs the same kind of agent through a hosted proxy every 15 minutes.
Try it
In the project whose MCP config your agent uses:
$ npx -p @agentwares/agentguard okgate init