How to Build an Audit Trail for MCP Tool Calls

April 01, 2026 mcp agents auditing compliance security cryptography

How to Build an Audit Trail for MCP Tool Calls

MCP sandboxes tool execution cleanly. Each tool call is isolated: the model can't see beyond what the server exposes, permissions are scoped, and the server controls what gets returned. This isolation story is solid.

The audit story isn't.

When an MCP tool call executes, you get a result. You don't get a tamper-evident record of what was called, with what arguments, at what time, what was returned, and who authorized it. If your agent runs delete_records(table="users", filter="status=inactive"), you might have an application log. But you don't have a cryptographic proof that the model called that tool with those arguments—something you can present to a regulator, an insurance carrier, or a downstream system that needs to verify the call chain.

This gap matters more as agents get more autonomy. An agent managing customer data, running financial calculations, or orchestrating infrastructure changes via MCP needs a traceable, tamper-evident record of every tool call—not just logs that your own system controls.

Here's a concrete pattern for building that.


The Problem with Existing Approaches

Application logs are self-attesting

Your MCP server logs tool calls. You log arguments and results. But these logs live in your infrastructure. If something goes wrong, you're presenting logs you control as evidence of what happened. A compliance auditor's job is to not trust self-attested logs.

Model-side context isn't proof

The language model sees tool call results in its context. This isn't a record of what happened—it's the model's runtime state. It evaporates when the session ends. It's also malleable: context can be modified between tool call and model consumption.

Request/response tracing misses the binding

API-level tracing (spans, traces) captures that a call happened. It doesn't cryptographically bind the model's intent (the call arguments it generated) to the tool's response. You can show "a call occurred," not "this model output requested this specific tool invocation and received this exact response."


The Pattern: Certifying Proxy + Hash Chain + Receipts

The pattern intercepts each tool call between the MCP client and server, generates a tamper-evident receipt, and chains receipts together so you can prove ordering and completeness.

MCP Client (Agent)
      │
      ▼
[Certifying Proxy]  ←── generates receipt, stores proof
      │
      ▼
MCP Server (Tool)
      │
      ▼
[Certifying Proxy]  ←── captures response, seals receipt
      │
      ▼
MCP Client (Agent)

Three components:

  1. Certifying proxy — intercepts calls, generates receipts
  2. Hash chain — links receipts so omissions are detectable
  3. Receipt format — the structure that makes each record verifiable

Component 1: The Certifying Proxy

The proxy sits between your MCP client and server. It doesn't modify calls—it witnesses them.

import hashlib
import hmac
import json
import os
import time
from dataclasses import dataclass, field
from typing import Any

@dataclass
class MCPCall:
    tool_name: str
    arguments: dict[str, Any]
    session_id: str
    call_id: str = field(default_factory=lambda: os.urandom(16).hex())

@dataclass  
class MCPReceipt:
    call_id: str
    session_id: str
    tool_name: str
    arguments_hash: str      # SHA-256 of canonical JSON args
    response_hash: str       # SHA-256 of canonical JSON response
    timestamp_ms: int
    prev_receipt_hash: str   # links to previous receipt (hash chain)
    receipt_hash: str        # hash of this receipt's fields
    signature: str           # HMAC-SHA256 over receipt_hash

class CertifyingProxy:
    def __init__(self, signing_key: bytes, storage_backend):
        self._key = signing_key
        self._storage = storage_backend
        self._last_receipt_hash = "genesis"  # chain starts here

    def intercept(self, call: MCPCall, tool_fn) -> tuple[Any, MCPReceipt]:
        ts = int(time.time() * 1000)

        # Hash arguments canonically (sorted keys, no whitespace)
        args_canonical = json.dumps(call.arguments, sort_keys=True, separators=(',', ':'))
        args_hash = hashlib.sha256(args_canonical.encode()).hexdigest()

        # Execute the actual tool call
        response = tool_fn(call.tool_name, call.arguments)

        # Hash response
        resp_canonical = json.dumps(response, sort_keys=True, separators=(',', ':'))
        resp_hash = hashlib.sha256(resp_canonical.encode()).hexdigest()

        # Build receipt fields (before signing)
        receipt_fields = {
            "call_id": call.call_id,
            "session_id": call.session_id,
            "tool_name": call.tool_name,
            "arguments_hash": args_hash,
            "response_hash": resp_hash,
            "timestamp_ms": ts,
            "prev_receipt_hash": self._last_receipt_hash,
        }

        # Hash the receipt itself
        receipt_canonical = json.dumps(receipt_fields, sort_keys=True, separators=(',', ':'))
        receipt_hash = hashlib.sha256(receipt_canonical.encode()).hexdigest()

        # Sign the receipt hash
        sig = hmac.new(self._key, receipt_hash.encode(), hashlib.sha256).hexdigest()

        receipt = MCPReceipt(
            **receipt_fields,
            receipt_hash=receipt_hash,
            signature=sig,
        )

        # Advance the chain
        self._last_receipt_hash = receipt_hash

        # Store (append-only)
        self._storage.append(receipt)

        return response, receipt

The proxy doesn't modify arguments or responses. The tool call executes normally. What changes: every call now has a tamper-evident record.


Component 2: The Hash Chain

Each receipt's prev_receipt_hash links to the previous receipt. This creates a chain: if someone removes a receipt or reorders them, the chain breaks.

def verify_chain(receipts: list[MCPReceipt], signing_key: bytes) -> list[str]:
    errors = []
    expected_prev = "genesis"

    for i, receipt in enumerate(receipts):
        # Check chain link
        if receipt.prev_receipt_hash != expected_prev:
            errors.append(
                f"Receipt {i} ({receipt.call_id}): chain break. "
                f"Expected prev={expected_prev[:8]}…, got {receipt.prev_receipt_hash[:8]}…"
            )

        # Recompute receipt hash
        fields = {
            "call_id": receipt.call_id,
            "session_id": receipt.session_id,
            "tool_name": receipt.tool_name,
            "arguments_hash": receipt.arguments_hash,
            "response_hash": receipt.response_hash,
            "timestamp_ms": receipt.timestamp_ms,
            "prev_receipt_hash": receipt.prev_receipt_hash,
        }
        canonical = json.dumps(fields, sort_keys=True, separators=(',', ':'))
        expected_hash = hashlib.sha256(canonical.encode()).hexdigest()

        if receipt.receipt_hash != expected_hash:
            errors.append(f"Receipt {i}: tampered (hash mismatch)")

        # Verify signature
        expected_sig = hmac.new(signing_key, receipt.receipt_hash.encode(), hashlib.sha256).hexdigest()
        if not hmac.compare_digest(receipt.signature, expected_sig):
            errors.append(f"Receipt {i}: invalid signature")

        expected_prev = receipt.receipt_hash

    return errors  # empty = chain intact

This gives you two properties:
- Completeness: you can detect if receipts were removed (chain breaks)
- Integrity: you can detect if any receipt was modified (hash mismatch)


Component 3: The Receipt Format

The receipt format above captures the minimum needed for an audit:

{
  "call_id": "a3f2c1d0e4b5...",
  "session_id": "session_20260403_prod",
  "tool_name": "delete_records",
  "arguments_hash": "sha256:e3b0c44298fc...",
  "response_hash": "sha256:9f86d081884c...",
  "timestamp_ms": 1743672000000,
  "prev_receipt_hash": "sha256:2c624232cc...",
  "receipt_hash": "sha256:4a8a08f09d...",
  "signature": "hmac-sha256:7f83b165..."
}

Note: arguments and responses are stored separately, with only their hashes in the receipt. This gives you:

  • Privacy: receipts don't expose sensitive argument values
  • Verifiability: given the original arguments, anyone can verify the hash matches
  • Compactness: the receipt chain is lightweight regardless of argument size

Store the original arguments and responses in a separate append-only store (S3, GCS, or even a local write-once file), keyed by call_id. The receipt chain proves their integrity; the storage holds the content.


Plugging Into an MCP Client

With the Python MCP SDK, intercept at the call_tool boundary:

from mcp import ClientSession
from mcp.client.stdio import stdio_client

class AuditedMCPClient:
    def __init__(self, proxy: CertifyingProxy):
        self._proxy = proxy

    async def call_tool(
        self, 
        session: ClientSession, 
        tool_name: str, 
        arguments: dict,
        session_id: str,
    ):
        call = MCPCall(
            tool_name=tool_name,
            arguments=arguments,
            session_id=session_id,
        )

        # Delegate actual execution to proxy, which calls through to MCP server
        def mcp_execute(name, args):
            # asyncio.run() blocks here — fine for a demo, but in production
            # this proxy should be async end-to-end to avoid blocking a running event loop
            import asyncio
            return asyncio.run(session.call_tool(name, args))

        result, receipt = self._proxy.intercept(call, mcp_execute)
        return result, receipt

The agent gets the result as usual. The proxy has recorded the receipt. The model has no visibility into the audit mechanism.


What This Doesn't Cover

Key management. The signing key is the weakest point. If an attacker rotates your key, they can rewrite the chain. Use a hardware key or a key management service (AWS KMS, HashiCorp Vault). The chain proves integrity relative to the key—key security is a prerequisite.

Argument pre-image. The receipt hashes arguments but doesn't store them. An attacker who controls the argument store could replace arguments while keeping the hash. Keep the argument store append-only, separate from the receipt store, and audit-log all writes.

Model authorization. This pattern records what happened. It doesn't record who authorized it. For regulated use cases, you need to bind each tool call to an authorization context: which human approved this agent action, under what policy. That's a separate layer (approval workflows, policy engines) that feeds metadata into the receipt.


The Compliance Argument

When an auditor asks "prove that your agent called delete_records with these arguments and received this response," you have a chain of receipts:

  1. Receipt for the call: arguments_hash, response_hash, timestamp_ms, signature
  2. Verify the chain is intact (no omissions, no modifications)
  3. Retrieve the original arguments from storage, recompute the hash, confirm it matches
  4. Verify the signature against the signing key

This is independently verifiable. You're not asking the auditor to trust your logs. You're giving them a cryptographic chain they can verify themselves.

The difference between "we logged it" and "here is tamper-evident proof" matters when the question is liability, not just observability.


Where to Go From Here

This pattern is a starting point. For production:

  • Replace HMAC with asymmetric signing (Ed25519) so verification doesn't require sharing the signing key
  • Anchor receipt chain hashes to an external append-only log (transparency log, blockchain, or a notary service) so chain integrity is provable without trusting your own infrastructure
  • Add authorization metadata to receipts (policy IDs, approver references, risk scores)
  • Build a receipt explorer so engineers can query the audit trail by session, tool, or time range

The proxy intercept pattern is the load-bearing piece. Everything else is strengthening its trust model.

MCP gives you sandboxed execution. Add a certifying proxy, and you get an auditable record of every action your agents take through tools. For systems that need to answer "what did the agent do, and can you prove it?"—this is the gap you're filling.