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), 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.
The Plain Server
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:
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:
- What if a malicious MCP proxy between the agent and your server mutates the response?
- What if the agent passes
weight_kg: 70but your handler receivesweight_kg: 700due to a man-in-the-middle on the transport? - What if the server binary gets swapped between sessions and the tool description changes?
- What if a delayed trigger fires 60 seconds after a specific input pattern?
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.
Adding CCS Receipts
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:
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.
What a Receipt Looks Like
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— SHA-256 of the canonical JSON-RPC request. If the request is mutated in transit, this won't match. -
response_hash— SHA-256 of the canonical response. Catches response tampering. -
params_hash— Binds the exact arguments. Detects parameter substitution. -
config_hash— Hash of the server's tool configuration at startup. Detects schema drift between sessions — a rug pull. -
latency_ms— Wall-clock duration. If a handler sleeps for 60 seconds, the latency dimension fails. -
signature— Ed25519 over all fields. Any tampering invalidates the signature. -
dimensions— Seven-dimension verdict: Structure, Schema, Latency, Cost, Identity, Integrity, Security.
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.
Verifying a Receipt on the Client Side
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:
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:
- The response was produced by the holder of the private key.
- The response corresponds to exactly the request that was sent.
- The parameters weren't substituted.
- The handler completed within the latency bound.
- The server configuration hasn't changed since the config hash was pinned.
Python Option
If your agent stack is Python, the ccs-verifier package on PyPI provides the same verification logic:
pip install ccs-verifier
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.
Performance
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.
Configuration Pinning
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:
// 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.
What This Catches (and What It Doesn't)
CCS receipts are a runtime verification mechanism. Here's where they help:
-
Response mutation — the
response_hashbinding means any change to the result after signing is detectable. -
Parameter tampering — the
params_hashbinds the exact argument set. If a proxy swaps a value, the hash won't match. -
Schema drift / rug pulls — the
config_hashpins the tool configuration. - Delayed triggers — the latency dimension flags handlers that take unusually long.
- Key compromise — Ed25519 signatures mean only the private key holder can produce valid receipts. Rotate keys and the old receipts stop verifying.
What receipts don't do:
- They don't sandbox the server process. If the server itself is malicious and holds the signing key, it can sign anything. CCS is a verifiable record, not a sandbox. For defense against a malicious server, you'd combine receipts with process isolation, capability-based permissions, and transport security.
- They don't replace code review or dependency scanning. They complement those practices by giving you runtime evidence that pre-deploy audits still hold.
- They don't verify the semantic correctness of the result. A correctly signed receipt can still contain a wrong answer if the handler has a bug. The receipt proves provenance and integrity, not accuracy.
Key Management in Production
In the example above, I generated a keypair at startup with crypto.generateKeyPair. That's fine for local development, but in production you should:
- Generate keys out-of-band and load them from a secrets manager (AWS KMS, HashiCorp Vault, environment variables injected at deploy time).
- Publish the public key through a trusted channel — your API documentation, a DNS TXT record, or a signed key distribution endpoint.
- Pin the public key on the client side. Don't fetch it over an unauthenticated connection on first use.
-
Support key rotation with a
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:
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",
});
The Full Before/After Diff
To make the integration cost concrete, here's the actual diff against the original server:
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.
Why I Built This
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, 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.
Wrapping Up
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
- GitHub: https://github.com/DSHCorrectover/ccs-mcp-server
- IETF Draft: https://datatracker.ietf.org/doc/draft-correctover-ccs/
Guigui Wang, Correctover
Top comments (0)