Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions apps/mcp/src/retrieval-receipt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest"
import { createRetrievalReceipt, toScoreBucket } from "./retrieval-receipt"

describe("retrieval receipts", () => {
it("hashes private values instead of exposing raw query, project, ids, or content", async () => {
const receipt = await createRetrievalReceipt({
query: "private cardiology PDF notes",
containerTag: "secret-client-project",
clientInfo: { name: "claude-code", version: "1.2.3" },
results: [
{
id: "mem_sensitive_1",
similarity: 0.87,
text: "Patient-specific private content",
},
],
total: 3,
latencyMs: 42,
profile: { staticCount: 2, dynamicCount: 1 },
})

expect(receipt).toMatchObject({
event: "memory.search.returned",
provider: "supermemory",
source: "mcp",
activation: "mcp_recall",
client: { name: "claude-code", version: "1.2.3" },
result: {
count: 1,
total: 3,
scoreBuckets: ["0.8-0.9"],
},
profile: { staticCount: 2, dynamicCount: 1 },
latencyMs: 42,
hashAlgorithm: "hmac-sha256-ephemeral-salt-prefix-16",
})

expect(receipt.queryHash).toHaveLength(16)
expect(receipt.projectIdHash).toHaveLength(16)
expect(receipt.result.idsHash).toEqual([
expect.stringMatching(/^[a-f0-9]{16}$/),
])
expect(receipt.result.contentHashes).toEqual([
expect.stringMatching(/^[a-f0-9]{16}$/),
])
expect(JSON.stringify(receipt)).not.toContain("private cardiology")
expect(JSON.stringify(receipt)).not.toContain("secret-client-project")
expect(JSON.stringify(receipt)).not.toContain("mem_sensitive_1")
expect(JSON.stringify(receipt)).not.toContain("Patient-specific")
})

it("keeps hashes equal within a receipt but unlinkable across receipts", async () => {
const args = {
query: "repeated query",
containerTag: "same-project",
results: [
{ id: "mem_dup", similarity: 0.5, text: "identical content" },
{ id: "mem_dup", similarity: 0.5, text: "identical content" },
],
latencyMs: 10,
}

const first = await createRetrievalReceipt(args)
const second = await createRetrievalReceipt(args)

// Within one receipt the same value hashes consistently, so duplicates
// remain detectable for debugging.
expect(first.result.idsHash[0]).toBe(first.result.idsHash[1])
expect(first.result.contentHashes[0]).toBe(first.result.contentHashes[1])

// Across receipts the same private value produces different tokens, so it
// cannot be correlated or dictionary-guessed without the ephemeral salt.
expect(second.queryHash).not.toBe(first.queryHash)
expect(second.projectIdHash).not.toBe(first.projectIdHash)
expect(second.result.idsHash[0]).not.toBe(first.result.idsHash[0])
expect(second.result.contentHashes[0]).not.toBe(
first.result.contentHashes[0],
)
})

it("keeps score buckets bounded at edges", () => {
expect(toScoreBucket(1)).toBe("1.0")
expect(toScoreBucket(0)).toBe("0.0-0.1")
expect(toScoreBucket(-0.2)).toBe("0.0-0.1")
expect(toScoreBucket(0.42)).toBe("0.4-0.5")
})
})
140 changes: 140 additions & 0 deletions apps/mcp/src/retrieval-receipt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
export type ReceiptMemoryResult = {
id: string
similarity: number
text: string
}

export type RetrievalReceipt = {
event: "memory.search.returned"
provider: "supermemory"
source: "mcp"
activation: "mcp_recall"
client?: {
name: string
version?: string
}
projectIdHash?: string
queryHash: string
result: {
count: number
total?: number
idsHash: string[]
scoreBuckets: string[]
contentHashes: string[]
}
profile?: {
staticCount: number
dynamicCount: number
}
latencyMs: number
hashAlgorithm: "hmac-sha256-ephemeral-salt-prefix-16"
}

type CreateRetrievalReceiptArgs = {
query: string
containerTag?: string
clientInfo?: { name: string; version?: string }
results: ReceiptMemoryResult[]
total?: number
latencyMs: number
profile?: {
staticCount: number
dynamicCount: number
}
}

const HASH_PREFIX_LENGTH = 16
const SALT_BYTES = 32

function bytesToHex(bytes: Uint8Array): string {
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(
"",
)
}

/**
* Builds a one-time keyed hasher for a single receipt.
*
* Plain deterministic SHA-256 is not privacy-safe for the values we hash here:
* queries, container tags, memory IDs, and (especially short) memory content
* are low-entropy, so a raw digest can be dictionary-guessed offline, and the
* same value would always produce the same digest and stay linkable across
* every receipt forever.
*
* Instead we key an HMAC with a cryptographically random salt that is generated
* per receipt and never emitted. Without the salt an attacker cannot precompute
* or brute-force the inputs, and because the salt is fresh for every receipt the
* same private value produces a different token each time, so receipts cannot be
* correlated against each other. Equality is preserved only within a single
* receipt (e.g. duplicate content in one result set), which is what debugging
* needs.
*/
async function createSaltedHasher(): Promise<
(value: string) => Promise<string>
> {
const salt = crypto.getRandomValues(new Uint8Array(SALT_BYTES))
const key = await crypto.subtle.importKey(
"raw",
salt,
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
)

return async (value: string): Promise<string> => {
const signature = await crypto.subtle.sign(
"HMAC",
key,
new TextEncoder().encode(value),
)
return bytesToHex(new Uint8Array(signature)).slice(0, HASH_PREFIX_LENGTH)
}
}

export function toScoreBucket(score: number): string {
if (score >= 1) return "1.0"
if (score <= 0) return "0.0-0.1"

const lower = Math.floor(score * 10) / 10
const upper = lower + 0.1
return `${lower.toFixed(1)}-${upper.toFixed(1)}`
}

export async function createRetrievalReceipt({
query,
containerTag,
clientInfo,
results,
total,
latencyMs,
profile,
}: CreateRetrievalReceiptArgs): Promise<RetrievalReceipt> {
const hash = await createSaltedHasher()

const [queryHash, projectIdHash, idsHash, contentHashes] = await Promise.all([
hash(query),
containerTag ? hash(containerTag) : Promise.resolve(undefined),
Promise.all(results.map((result) => hash(result.id))),
Promise.all(results.map((result) => hash(result.text))),
])

return {
event: "memory.search.returned",
provider: "supermemory",
source: "mcp",
activation: "mcp_recall",
...(clientInfo ? { client: clientInfo } : {}),
...(projectIdHash ? { projectIdHash } : {}),
queryHash,
result: {
count: results.length,
...(total === undefined ? {} : { total }),
idsHash,
scoreBuckets: results.map((result) => toScoreBucket(result.similarity)),
contentHashes,
},
...(profile ? { profile } : {}),
latencyMs,
hashAlgorithm: "hmac-sha256-ephemeral-salt-prefix-16",
}
}
Loading