C.W.K.
Stream
Lesson 04 of 05 · published

Human Approval and Audit Trails

~22 min · approval, audit, destructive, logging

Level 0Curious Reader
0 XP0/48 lessons0/14 achievements
0/100 XP to next level100 XP to go0% complete

Two operational habits make security real: human-in-the-loop on the dangerous edge and append-only audit logs. Neither is glamorous; both are what separates an agent that can ship from an agent that can't.

Approval is the pattern from the side-effects lesson: split irreversible writes into propose_X and execute_X with the host responsible for getting the user's explicit OK between them. MCP makes this machine-checkable through tool annotations (destructiveHint, readOnlyHint); a well-built host displays a different confirmation UI for tools whose annotations claim destruction. The protocol does not enforce honesty on annotations, but the host can — and reputation enforces it across servers.

Audit logs are append-only structured records of every tool call: which client, which tool, which arguments, which result, when, by which user (if known). They live on the server side; the client cannot rewrite them. When something goes wrong six months from now ("did the bot really refund $400 to that order?") the audit log is the answer and the only answer. JSON-lines on disk, one file per day, ship to your existing log infrastructure — the cost is small and the rescue value is enormous.

Audit logs are also where you spot abuse. A spike in a particular tool, a pattern of arguments that suggests scraping, an OAuth token used from suspicious IPs — all of these show up in the log first. Treat the log as the security telescope, not as compliance paperwork.

Code

Approval-gated execute tool·python
@app.tool(annotations={"destructiveHint": True})
async def execute_refund(proposal_id: str, approved_by_user: bool):
    if not approved_by_user:
        return [TextContent(type="text",
                  text="Refund execution requires explicit user approval (approved_by_user=true).")]
    audit.log("execute_refund", proposal_id=proposal_id, user=current_user())
    receipt = await stripe.refund(...)
    audit.log("execute_refund.done", receipt_id=receipt.id)
    return [TextContent(type="text", text=f"Refunded. Receipt: {receipt.id}")]
Append-only audit log helper·python
import json, time, pathlib

LOG = pathlib.Path("/var/log/mcp-server/audit.jsonl")

def audit(event: str, **fields):
    rec = {"ts": time.time(), "event": event, **fields}
    with LOG.open("a") as f:
        f.write(json.dumps(rec, separators=(',', ':')) + "\n")

External links

Exercise

For one server you maintain: list every tool, mark each R/W/D, design the propose/execute split for every D, and add a one-line audit entry to every W and D. Ship that as a PR. The diff will be small; the security posture improvement is real.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.