{"slug": "i-added-cryptographic-receipts-to-mcp-tool-calls-in-20-lines-of-code", "title": "I Added Cryptographic Receipts to MCP Tool Calls in 20 Lines of Code", "summary": "A developer has introduced a technique for adding cryptographically signed receipts to MCP tool calls, using the ccs-mcp-server library. The method, which adds about 20 lines of code, helps detect argument tampering, response mutation, and schema drift. The library implements the Correctover Conformance Shape (CCS), an IETF Internet-Draft for agent runtime verification.", "body_md": "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.\n\nI'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.\n\nThe library is `ccs-mcp-server`\n\n. 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.\n\nThis is a hands-on tutorial. We'll start with a plain MCP server, add receipts, and look at what you get.\n\nHere's a minimal MCP server in TypeScript. It exposes a single tool, `calculate_bmi`\n\n, that takes a weight and height and returns a BMI value:\n\n``` js\nimport { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";\nimport { StdioServerTransport } from \"@modelcontextprotocol/sdk/server/stdio.js\";\nimport { z } from \"zod\";\n\nconst server = new McpServer({ name: \"health-tools\", version: \"1.0.0\" });\n\nserver.tool(\n  \"calculate_bmi\",\n  \"Calculate BMI from weight (kg) and height (m)\",\n  {\n    weight_kg: z.number().positive(),\n    height_m: z.number().positive(),\n  },\n  async ({ weight_kg, height_m }) => {\n    const bmi = weight_kg / (height_m * height_m);\n    return {\n      content: [{ type: \"text\", text: `BMI: ${bmi.toFixed(1)}` }],\n    };\n  }\n);\n\nconst transport = new StdioServerTransport();\nawait server.connect(transport);\n```\n\nThis works. The agent calls `calculate_bmi`\n\n, gets a number. But consider:\n\n`weight_kg: 70`\n\nbut your handler receives `weight_kg: 700`\n\ndue 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.\n\nInstall the package:\n\n```\nnpm install ccs-mcp-server\n```\n\nNow wrap your handler. The `ccs-mcp-server`\n\npackage exports a `withReceipt`\n\nhigher-order function and a `createVerifier`\n\nfactory. You generate an Ed25519 keypair at startup, wrap each tool handler, and the receipt is generated and attached automatically:\n\n``` js\nimport { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";\nimport { StdioServerTransport } from \"@modelcontextprotocol/sdk/server/stdio.js\";\nimport { z } from \"zod\";\nimport { createVerifier, withReceipt } from \"ccs-mcp-server\";\nimport { generateKeyPair } from \"crypto\";\n\n// 1. Generate or load an Ed25519 signing key\nconst { publicKey, privateKey } = await new Promise<{\n  publicKey: string;\n  privateKey: string;\n}>((resolve, reject) => {\n  generateKeyPair(\"ed25519\", (err, pub, priv) => {\n    if (err) reject(err);\n    resolve({\n      publicKey: pub.export({ format: \"pem\", type: \"spki\" }).toString(),\n      privateKey: priv.export({ format: \"pem\", type: \"pkcs8\" }).toString(),\n    });\n  });\n});\n\n// 2. Create a verifier bound to your server identity\nconst verifier = createVerifier({\n  issuer: \"health-tools\",\n  audience: \"mcp-agent\",\n  privateKey,\n  publicKey,\n});\n\nconst server = new McpServer({ name: \"health-tools\", version: \"1.0.0\" });\n\n// 3. Wrap your handler — the receipt is generated and signed\nserver.tool(\n  \"calculate_bmi\",\n  \"Calculate BMI from weight (kg) and height (m)\",\n  {\n    weight_kg: z.number().positive(),\n    height_m: z.number().positive(),\n  },\n  withReceipt(verifier, async ({ weight_kg, height_m }) => {\n    const bmi = weight_kg / (height_m * height_m);\n    return {\n      content: [{ type: \"text\", text: `BMI: ${bmi.toFixed(1)}` }],\n    };\n  })\n);\n\nconst transport = new StdioServerTransport();\nawait server.connect(transport);\n```\n\nThat'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.\n\nWhen the agent calls `calculate_bmi({ weight_kg: 70, height_m: 1.75 })`\n\n, `withReceipt`\n\nintercepts the call, computes a canonical hash of the arguments, executes your handler, hashes the response, and signs a receipt:\n\n```\n{\n  \"iss\": \"health-tools\",\n  \"aud\": \"mcp-agent\",\n  \"iat\": 1756000000,\n  \"exp\": 1756000300,\n  \"jti\": \"a1b2c3d4-e5f6-7890-abcd-ef1234567890\",\n  \"action\": \"calculate_bmi\",\n  \"verdict\": \"permit\",\n  \"request_hash\": \"sha256:9f86d081884c7d659a2feaa0c55ad015\",\n  \"response_hash\": \"sha256:4e07d5f7c6c5b3a1e2d4f5a6b7c8d9e0\",\n  \"params_hash\": \"sha256:7c8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b\",\n  \"runtime_context_hash\": \"sha256:1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d\",\n  \"config_hash\": \"sha256:f1e2d3c4b5a69788796a5b4c3d2e1f0a\",\n  \"latency_ms\": 1.2,\n  \"dimensions\": {\n    \"structure\": \"pass\",\n    \"schema\": \"pass\",\n    \"latency\": \"pass\",\n    \"cost\": \"pass\",\n    \"identity\": \"pass\",\n    \"integrity\": \"pass\",\n    \"security\": \"pass\"\n  },\n  \"signature\": \"ed25519:MEUCIQD...\"\n}\n```\n\nThe receipt is returned alongside the tool result as a `structuredContent`\n\nfield, so any MCP client can inspect it. The key fields:\n\n`request_hash`\n\n`response_hash`\n\n`params_hash`\n\n`config_hash`\n\n`latency_ms`\n\n`signature`\n\n`dimensions`\n\nThe verifier is fail-closed: if any dimension throws, times out, or returns an ambiguous result, the verdict is `deny`\n\nand the tool result is not delivered.\n\nGenerating 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:\n\n``` js\nimport { verifyReceipt } from \"ccs-mcp-server\";\n\n// After receiving a tool result with a receipt:\nconst result = await verifyReceipt(receipt, {\n  publicKey: trustedServerPublicKey,\n  audience: \"mcp-agent\",\n  maxLatencyMs: 5000,\n});\n\nif (!result.valid) {\n  console.error(`Receipt verification failed: ${result.reason}`);\n  // Don't trust the tool result. Log it. Block it.\n} else {\n  console.log(`Verified ${receipt.action} (${receipt.latency_ms}ms)`);\n}\n```\n\nThe 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`\n\ngives you cryptographic certainty that:\n\nIf your agent stack is Python, the `ccs-verifier`\n\npackage on PyPI provides the same verification logic:\n\n```\npip install ccs-verifier\npython\nfrom ccs_verifier import verify_receipt\n\nresult = verify_receipt(\n    receipt,\n    public_key=trusted_server_public_key,\n    audience=\"mcp-agent\",\n    max_latency_ms=5000,\n)\n\nif not result.valid:\n    raise RuntimeError(f\"Receipt verification failed: {result.reason}\")\n```\n\nThe 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.\n\nVerification overhead is sub-millisecond. I benchmarked both implementations on a M2 MacBook Air:\n\n| Runtime | P50 Latency | Measurement |\n|---|---|---|\nNode.js (`ccs-mcp-server` , in-process) |\n~2.7μs | Sign + verify, local call |\nPython (`ccs-verifier` , end-to-end) |\n~27μs | Receipt parse + verify |\n\nThe 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.\n\nThe `config_hash`\n\nfield deserves a closer look because it addresses a real operational problem: schema drift.\n\nWhen your MCP server starts up, `createVerifier`\n\nhashes 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`\n\nafter your first verified interaction:\n\n``` js\n// After the first successful verification:\nconst pinnedConfigHash = receipt.config_hash;\n\n// On subsequent calls:\nconst result = await verifyReceipt(receipt, {\n  publicKey: trustedServerPublicKey,\n  audience: \"mcp-agent\",\n  maxLatencyMs: 5000,\n  expectedConfigHash: pinnedConfigHash,\n});\n```\n\nIf the server's tool definitions change between sessions — an updated package, a compromised binary, a silent push — the `config_hash`\n\nwon'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.\n\nCCS receipts are a runtime verification mechanism. Here's where they help:\n\n`response_hash`\n\nbinding means any change to the result after signing is detectable.`params_hash`\n\nbinds the exact argument set. If a proxy swaps a value, the hash won't match.`config_hash`\n\npins the tool configuration.What receipts don't do:\n\nIn the example above, I generated a keypair at startup with `crypto.generateKeyPair`\n\n. That's fine for local development, but in production you should:\n\n`kid`\n\n(key ID) field in the receipt header, so clients can try multiple trusted keys during rotation windows.The `createVerifier`\n\nfunction accepts a `keyId`\n\noption for this purpose:\n\n``` js\nconst verifier = createVerifier({\n  issuer: \"health-tools\",\n  audience: \"mcp-agent\",\n  privateKey: process.env.CCS_PRIVATE_KEY!,\n  publicKey: process.env.CCS_PUBLIC_KEY!,\n  keyId: process.env.CCS_KEY_ID ?? \"primary\",\n});\n```\n\nTo make the integration cost concrete, here's the actual diff against the original server:\n\n``` js\n import { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";\n import { StdioServerTransport } from \"@modelcontextprotocol/sdk/server/stdio.js\";\n import { z } from \"zod\";\n+import { createVerifier, withReceipt } from \"ccs-mcp-server\";\n\n+const verifier = createVerifier({\n+  issuer: \"health-tools\",\n+  audience: \"mcp-agent\",\n+  privateKey: process.env.CCS_PRIVATE_KEY!,\n+  publicKey: process.env.CCS_PUBLIC_KEY!,\n+});\n\n const server = new McpServer({ name: \"health-tools\", version: \"1.0.0\" });\n\n server.tool(\n   \"calculate_bmi\",\n   \"Calculate BMI from weight (kg) and height (m)\",\n   { weight_kg: z.number().positive(), height_m: z.number().positive() },\n-  async ({ weight_kg, height_m }) => {\n+  withReceipt(verifier, async ({ weight_kg, height_m }) => {\n     const bmi = weight_kg / (height_m * height_m);\n     return { content: [{ type: \"text\", text: `BMI: ${bmi.toFixed(1)}` }] };\n-  }\n+  })\n );\n```\n\nThat'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.\n\nI 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.\n\nCCS 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.\"\n\nThe 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.\n\nAdding 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.\n\nIf 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.\n\nTry it: `npm install ccs-mcp-server`\n\n*Guigui Wang, Correctover*", "url": "https://wpnews.pro/news/i-added-cryptographic-receipts-to-mcp-tool-calls-in-20-lines-of-code", "canonical_source": "https://dev.to/correctover/i-added-cryptographic-receipts-to-mcp-tool-calls-in-20-lines-of-code-4h7o", "published_at": "2026-08-24 08:32:22+00:00", "updated_at": "2026-08-24 08:43:08.077100+00:00", "lang": "en", "topics": ["developer-tools", "ai-agents", "ai-safety"], "entities": ["ccs-mcp-server", "Correctover Conformance Shape", "IETF", "Elastic License 2.0", "MCP"], "alternates": {"html": "https://wpnews.pro/news/i-added-cryptographic-receipts-to-mcp-tool-calls-in-20-lines-of-code", "markdown": "https://wpnews.pro/news/i-added-cryptographic-receipts-to-mcp-tool-calls-in-20-lines-of-code.md", "text": "https://wpnews.pro/news/i-added-cryptographic-receipts-to-mcp-tool-calls-in-20-lines-of-code.txt", "jsonld": "https://wpnews.pro/news/i-added-cryptographic-receipts-to-mcp-tool-calls-in-20-lines-of-code.jsonld"}}