# I Added Cryptographic Receipts to MCP Tool Calls in 20 Lines of Code

> Source: <https://dev.to/correctover/i-added-cryptographic-receipts-to-mcp-tool-calls-in-20-lines-of-code-4h7o>
> Published: 2026-08-24 08:32:22+00:00

If you've built an MCP server, you know the drill: define a tool, write a handler, return a result. The SDK handles the protocol, the transport, the schema validation. It feels clean. It also means you have zero verifiable evidence that what your handler returned is what the agent actually received — or that the arguments the agent passed are what your handler expected.

I'm not here to scare you with supply chain horror stories. I want to show you a technique I've been using: attaching a cryptographically signed receipt to every MCP tool call. It catches argument tampering, response mutation, schema drift, and delayed-trigger attacks — and it adds about 20 lines of code to an existing server.

The library is `ccs-mcp-server`

. It implements the [Correctover Conformance Shape (CCS)](https://datatracker.ietf.org/doc/draft-correctover-ccs/), an IETF Internet-Draft that defines a receipt schema and binding specification for agent runtime verification. The reference implementation is source-available under the Elastic License 2.0.

This is a hands-on tutorial. We'll start with a plain MCP server, add receipts, and look at what you get.

Here's a minimal MCP server in TypeScript. It exposes a single tool, `calculate_bmi`

, that takes a weight and height and returns a BMI value:

``` js
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

const server = new McpServer({ name: "health-tools", version: "1.0.0" });

server.tool(
  "calculate_bmi",
  "Calculate BMI from weight (kg) and height (m)",
  {
    weight_kg: z.number().positive(),
    height_m: z.number().positive(),
  },
  async ({ weight_kg, height_m }) => {
    const bmi = weight_kg / (height_m * height_m);
    return {
      content: [{ type: "text", text: `BMI: ${bmi.toFixed(1)}` }],
    };
  }
);

const transport = new StdioServerTransport();
await server.connect(transport);
```

This works. The agent calls `calculate_bmi`

, gets a number. But consider:

`weight_kg: 70`

but your handler receives `weight_kg: 700`

due to a man-in-the-middle on the transport?You can't tell. The result comes back as plain text over stdio. There's no signature, no binding to the original request, no record of what was evaluated.

Install the package:

```
npm install ccs-mcp-server
```

Now wrap your handler. The `ccs-mcp-server`

package exports a `withReceipt`

higher-order function and a `createVerifier`

factory. You generate an Ed25519 keypair at startup, wrap each tool handler, and the receipt is generated and attached automatically:

``` js
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { createVerifier, withReceipt } from "ccs-mcp-server";
import { generateKeyPair } from "crypto";

// 1. Generate or load an Ed25519 signing key
const { publicKey, privateKey } = await new Promise<{
  publicKey: string;
  privateKey: string;
}>((resolve, reject) => {
  generateKeyPair("ed25519", (err, pub, priv) => {
    if (err) reject(err);
    resolve({
      publicKey: pub.export({ format: "pem", type: "spki" }).toString(),
      privateKey: priv.export({ format: "pem", type: "pkcs8" }).toString(),
    });
  });
});

// 2. Create a verifier bound to your server identity
const verifier = createVerifier({
  issuer: "health-tools",
  audience: "mcp-agent",
  privateKey,
  publicKey,
});

const server = new McpServer({ name: "health-tools", version: "1.0.0" });

// 3. Wrap your handler — the receipt is generated and signed
server.tool(
  "calculate_bmi",
  "Calculate BMI from weight (kg) and height (m)",
  {
    weight_kg: z.number().positive(),
    height_m: z.number().positive(),
  },
  withReceipt(verifier, async ({ weight_kg, height_m }) => {
    const bmi = weight_kg / (height_m * height_m);
    return {
      content: [{ type: "text", text: `BMI: ${bmi.toFixed(1)}` }],
    };
  })
);

const transport = new StdioServerTransport();
await server.connect(transport);
```

That's the diff. The key generation is boilerplate; the actual integration — creating the verifier and wrapping the handler — is about 20 lines of meaningful code. If you already have a keypair (which you should in production), it's closer to 5.

When the agent calls `calculate_bmi({ weight_kg: 70, height_m: 1.75 })`

, `withReceipt`

intercepts the call, computes a canonical hash of the arguments, executes your handler, hashes the response, and signs a receipt:

```
{
  "iss": "health-tools",
  "aud": "mcp-agent",
  "iat": 1756000000,
  "exp": 1756000300,
  "jti": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "action": "calculate_bmi",
  "verdict": "permit",
  "request_hash": "sha256:9f86d081884c7d659a2feaa0c55ad015",
  "response_hash": "sha256:4e07d5f7c6c5b3a1e2d4f5a6b7c8d9e0",
  "params_hash": "sha256:7c8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b",
  "runtime_context_hash": "sha256:1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d",
  "config_hash": "sha256:f1e2d3c4b5a69788796a5b4c3d2e1f0a",
  "latency_ms": 1.2,
  "dimensions": {
    "structure": "pass",
    "schema": "pass",
    "latency": "pass",
    "cost": "pass",
    "identity": "pass",
    "integrity": "pass",
    "security": "pass"
  },
  "signature": "ed25519:MEUCIQD..."
}
```

The receipt is returned alongside the tool result as a `structuredContent`

field, so any MCP client can inspect it. The key fields:

`request_hash`

`response_hash`

`params_hash`

`config_hash`

`latency_ms`

`signature`

`dimensions`

The verifier is fail-closed: if any dimension throws, times out, or returns an ambiguous result, the verdict is `deny`

and the tool result is not delivered.

Generating receipts is only half the picture. The agent (or a middleware layer) needs to verify them. If you're working in Node.js, you can use the same package:

``` js
import { verifyReceipt } from "ccs-mcp-server";

// After receiving a tool result with a receipt:
const result = await verifyReceipt(receipt, {
  publicKey: trustedServerPublicKey,
  audience: "mcp-agent",
  maxLatencyMs: 5000,
});

if (!result.valid) {
  console.error(`Receipt verification failed: ${result.reason}`);
  // Don't trust the tool result. Log it. Block it.
} else {
  console.log(`Verified ${receipt.action} (${receipt.latency_ms}ms)`);
}
```

The verifier checks the Ed25519 signature, validates the audience binding, checks freshness (issued-at and expiration), recomputes all hashes independently, and evaluates each dimension. A receipt that passes `verifyReceipt`

gives you cryptographic certainty that:

If your agent stack is Python, the `ccs-verifier`

package on PyPI provides the same verification logic:

```
pip install ccs-verifier
python
from ccs_verifier import verify_receipt

result = verify_receipt(
    receipt,
    public_key=trusted_server_public_key,
    audience="mcp-agent",
    max_latency_ms=5000,
)

if not result.valid:
    raise RuntimeError(f"Receipt verification failed: {result.reason}")
```

The Python implementation is a clean-room port of the verifier logic, not a subprocess wrapper. It handles Ed25519 verification, canonical JSON serialization, hash recomputation, and dimension evaluation entirely in-process.

Verification overhead is sub-millisecond. I benchmarked both implementations on a M2 MacBook Air:

| Runtime | P50 Latency | Measurement |
|---|---|---|
Node.js (`ccs-mcp-server` , in-process) |
~2.7μs | Sign + verify, local call |
Python (`ccs-verifier` , end-to-end) |
~27μs | Receipt parse + verify |

The Node.js path is faster because signing and verification happen in the same process with no serialization boundary. The Python number includes JSON parsing, base64 decoding, and Ed25519 verification — the full receipt intake path. Either way, you're looking at overhead that's invisible compared to a typical LLM tool call, which takes hundreds of milliseconds.

The `config_hash`

field deserves a closer look because it addresses a real operational problem: schema drift.

When your MCP server starts up, `createVerifier`

hashes the canonical configuration — tool names, descriptions, input schemas, and version — and bakes it into every receipt. On the client side, you pin the expected `config_hash`

after your first verified interaction:

``` js
// After the first successful verification:
const pinnedConfigHash = receipt.config_hash;

// On subsequent calls:
const result = await verifyReceipt(receipt, {
  publicKey: trustedServerPublicKey,
  audience: "mcp-agent",
  maxLatencyMs: 5000,
  expectedConfigHash: pinnedConfigHash,
});
```

If the server's tool definitions change between sessions — an updated package, a compromised binary, a silent push — the `config_hash`

won't match and verification fails. You get a clear signal: "this server is not the server you trusted." No more discovering schema changes through broken prompts or unexpected behavior.

CCS receipts are a runtime verification mechanism. Here's where they help:

`response_hash`

binding means any change to the result after signing is detectable.`params_hash`

binds the exact argument set. If a proxy swaps a value, the hash won't match.`config_hash`

pins the tool configuration.What receipts don't do:

In the example above, I generated a keypair at startup with `crypto.generateKeyPair`

. That's fine for local development, but in production you should:

`kid`

(key ID) field in the receipt header, so clients can try multiple trusted keys during rotation windows.The `createVerifier`

function accepts a `keyId`

option for this purpose:

``` js
const verifier = createVerifier({
  issuer: "health-tools",
  audience: "mcp-agent",
  privateKey: process.env.CCS_PRIVATE_KEY!,
  publicKey: process.env.CCS_PUBLIC_KEY!,
  keyId: process.env.CCS_KEY_ID ?? "primary",
});
```

To make the integration cost concrete, here's the actual diff against the original server:

``` js
 import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
 import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
 import { z } from "zod";
+import { createVerifier, withReceipt } from "ccs-mcp-server";

+const verifier = createVerifier({
+  issuer: "health-tools",
+  audience: "mcp-agent",
+  privateKey: process.env.CCS_PRIVATE_KEY!,
+  publicKey: process.env.CCS_PUBLIC_KEY!,
+});

 const server = new McpServer({ name: "health-tools", version: "1.0.0" });

 server.tool(
   "calculate_bmi",
   "Calculate BMI from weight (kg) and height (m)",
   { weight_kg: z.number().positive(), height_m: z.number().positive() },
-  async ({ weight_kg, height_m }) => {
+  withReceipt(verifier, async ({ weight_kg, height_m }) => {
     const bmi = weight_kg / (height_m * height_m);
     return { content: [{ type: "text", text: `BMI: ${bmi.toFixed(1)}` }] };
-  }
+  })
 );
```

That's the whole thing. One import, one verifier initialization, one wrapper around the handler. The key loading is a deployment concern, not a code concern.

I was working on an MCP deployment where agents were calling internal tools — database queries, file operations, internal API triggers. We had good pre-deploy security: SAST, dependency scanning, code review. But once the server was running, we had no way to prove that a given tool call happened correctly. Logs were text. Responses were unsigned. If something went wrong, we were reconstructing events from STDIO captures and timestamps.

CCS receipts gave us a per-invocation, cryptographically verifiable record. Every tool call produces a receipt. Every receipt is signed. Every signature can be verified independently, months later, without trusting the server that produced it. It's the difference between "the log says it happened" and "we can prove it happened."

The specification is an [IETF Internet-Draft](https://datatracker.ietf.org/doc/draft-correctover-ccs/), so the receipt format is documented and versioned. If you want to implement your own verifier or signer, the draft has the full schema, the nine binding mechanisms, and the negative test cases.

Adding cryptographic receipts to MCP tool calls doesn't require rearchitecting your server. It doesn't require a sidecar. It doesn't require a new transport. It's a wrapper around your existing handlers, a keypair, and a verifier on the consuming side. The overhead is measured in microseconds. The audit trail is permanent.

If you're running MCP servers that touch anything sensitive — internal APIs, user data, infrastructure controls — you should have a verifiable record of what those servers did. Not text logs. Signed receipts.

Try it: `npm install ccs-mcp-server`

*Guigui Wang, Correctover*
