Proving an MCP Tool Call Happened: A Complete Walkthrough

April 04, 2026 mcp python security agents cryptography verification

Proving an MCP Tool Call Happened: A Complete Walkthrough

An MCP agent calls send_email(to="alice@acme.com", subject="Invoice #4021"). The tool returns {"status": "sent"}. Three weeks later, Alice says she never received it.

Who is right? You have the agent's word. Alice has hers. The MCP server returned a string. The upstream SMTP API might have failed silently. There is no independent record of what was sent, when, or what the API actually responded.

This is the default state of every MCP tool call: no verifiable evidence that the action occurred.

This walkthrough fixes that. By the end, you will have a cryptographic receipt for a tool call -- signed, timestamped by an independent authority, and anchored in a public transparency log. Three witnesses, none of which is the system that executed the action.

What MCP gives you by default

Here is a standard MCP server with a send_email tool:

# email_server.py
import httpx
from mcp.server import Server

server = Server("email-tools")

@server.call_tool()
async def handle_tool(name: str, arguments: dict):
    if name == "send_email":
        resp = await httpx.post(
            "https://api.sendgrid.com/v3/mail/send",
            headers={"Authorization": f"Bearer {SENDGRID_KEY}"},
            json=build_payload(arguments),
        )
        return {"status": "sent", "code": resp.status_code}

The client gets {"status": "sent", "code": 202}. That is the tool's self-report. Nothing else exists. The HTTP response from SendGrid is gone -- consumed and discarded in the same process that made the call.

If you log the response, you now have a log entry. But that entry was written by the same server that executed the call. It can be edited, deleted, or was never written in the first place if the process crashed between the API call and the log write.

Adding a receipt: the three-line change

Route the outbound API call through a certifying proxy. The proxy forwards your request to the upstream API, captures the exact request and response bytes, and returns a cryptographic receipt alongside the original response.

# email_server.py -- with receipts
import httpx
from mcp.server import Server

TRUST_PROXY = "https://trust.arkforge.tech/v1/proxy"
API_KEY = "your_arkforge_api_key"

server = Server("email-tools")

@server.call_tool()
async def handle_tool(name: str, arguments: dict):
    if name == "send_email":
        resp = await httpx.post(
            TRUST_PROXY,                          # <-- change 1: route through proxy
            headers={"X-Api-Key": API_KEY},        # <-- change 2: authenticate
            json={
                "target": "https://api.sendgrid.com/v3/mail/send",
                "method": "POST",
                "payload": build_payload(arguments),
                "extra_headers": {"Authorization": f"Bearer {SENDGRID_KEY}"},
            },
        )
        data = resp.json()
        return {
            "status": "sent",
            "code": data["service_response"]["status_code"],
            "_proof_id": data["proof"]["proof_id"],  # <-- change 3: surface proof
        }

The upstream API call still happens. SendGrid still receives the exact same request. The only difference: a neutral third party now has a signed record of what was sent and what came back.

What is inside a receipt

The proxy returns a proof object alongside the original API response. Here is what it contains (non-essential fields omitted):

{
  "proof_id": "prf_20260404_140312_a8c3f1",
  "spec_version": "1.2",
  "timestamp": "2026-04-04T14:03:12Z",
  "hashes": {
    "request":  "sha256:3b4c...a91f",
    "response": "sha256:e7d2...c044",
    "chain":    "sha256:91ab...f3e8"
  },
  "parties": {
    "buyer_fingerprint": "sha256:your_api_key_hash",
    "seller": "api.sendgrid.com"
  },
  "arkforge_signature": "ed25519:KjG8...rQ==",
  "arkforge_pubkey": "ed25519:ZLlG...fEY",
  "timestamp_authority": {
    "status": "verified",
    "provider": "freetsa.org"
  },
  "transparency_log": {
    "provider": "sigstore-rekor",
    "status": "success",
    "entry_uuid": "24296fb5..."
  },
  "verification_url": "https://trust.arkforge.tech/v1/proof/prf_20260404_140312_a8c3f1"
}

Three independent witnesses:

  1. Ed25519 signature -- the proxy signed the chain hash. Verifiable with the public key at trust.arkforge.tech/v1/pubkey.
  2. RFC 3161 timestamp -- an independent Timestamp Authority certified the time. The TSA has no relationship with the proxy, the agent, or the upstream API.
  3. Sigstore Rekor entry -- the chain hash was submitted to a public, append-only transparency log operated by the Linux Foundation. Anyone can search it at search.sigstore.dev.

The chain hash binds the request hash, response hash, timestamp, and parties into a single value. Changing any field invalidates the chain. The chain hash is what gets signed, timestamped, and logged.

Verifying a receipt without trusting anyone

Verification does not require trusting the proxy. It requires math.

import hashlib, json, httpx
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from base64 import urlsafe_b64decode

# 1. Fetch the proof
proof = httpx.get(
    "https://trust.arkforge.tech/v1/proof/prf_20260404_140312_a8c3f1"
).json()

# 2. Recompute the chain hash from its inputs
chain_data = {
    "request_hash": proof["hashes"]["request"],
    "response_hash": proof["hashes"]["response"],
    "transaction_id": proof["proof_id"],
    "timestamp": proof["timestamp"],
    "buyer_fingerprint": proof["parties"]["buyer_fingerprint"],
    "seller": proof["parties"]["seller"],
}
canonical = json.dumps(chain_data, sort_keys=True, separators=(",", ":"))
expected_chain = "sha256:" + hashlib.sha256(canonical.encode()).hexdigest()

assert expected_chain == proof["hashes"]["chain"], "Chain hash mismatch"

# 3. Verify the Ed25519 signature
pubkey_b64 = proof["arkforge_pubkey"].split(":")[1] + "="
pubkey = Ed25519PublicKey.from_public_bytes(urlsafe_b64decode(pubkey_b64))
sig_b64 = proof["arkforge_signature"].split(":")[1] + "="
pubkey.verify(
    urlsafe_b64decode(sig_b64),
    proof["hashes"]["chain"].split(":")[1].encode()
)
print("Signature valid.")

# 4. Check Rekor (optional -- proves the hash was logged publicly)
rekor_uuid = proof["transparency_log"]["entry_uuid"]
rekor = httpx.get(
    f"https://rekor.sigstore.dev/api/v1/log/entries/{rekor_uuid}"
).json()
print(f"Rekor entry exists. Logged at index: {list(rekor.values())[0]['logIndex']}")

If step 2 passes, the chain hash matches its inputs. If step 3 passes, the proxy signed that exact chain hash. If step 4 passes, the hash was publicly logged before anyone knew it would be checked. No single party -- not the proxy, not the agent, not the upstream API -- can forge this combination.

Back to Alice's missing email

With the receipt, the dispute has a resolution path:

  • The request hash proves the exact payload sent to SendGrid, including the recipient address and subject line.
  • The response hash proves SendGrid's exact response (status code, message ID).
  • The timestamp proves when the exchange happened, certified by an authority independent of both parties.

If SendGrid returned 202 Accepted and the receipt confirms it, the email was accepted for delivery. If Alice's mail server rejected it downstream, that is a different problem -- but the agent's part of the chain is now verifiable.

Without the receipt, it is Alice's word against a log file that anyone with server access could have written after the fact.

What it costs

The free tier covers 500 receipts per month. No credit card required. Each receipt adds roughly 200ms of latency (proxy round-trip + timestamp authority). For most MCP tool calls -- API integrations, database writes, webhook dispatches -- that overhead is negligible compared to the upstream call itself.

For higher volumes: plans start at EUR 29/month for 5,000 receipts.

When to use this

Not every tool call needs a receipt. search_web probably does not. But any tool call where you might later need to prove what happened -- payments, emails, data mutations, cross-organization API calls -- is a candidate.

The decision heuristic: if the tool call's result could be disputed by another party, add a receipt.


The ArkForge Trust Layer is an open-architecture certifying proxy for MCP and API calls. The proof specification is public. The verification algorithm requires no proprietary software.


Prove it happened. Cryptographically.

ArkForge generates independent, verifiable proofs for every API call your agents make. Free tier included.

Get my free API key → See pricing