feat(mcp): add privacy-safe recall receipts

This commit is contained in:
cat0825 2026-06-01 22:20:01 +08:00
parent 2684990688
commit 3ac6575db0
3 changed files with 250 additions and 14 deletions

View file

@ -0,0 +1,58 @@
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: "sha256-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 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")
})
})

View file

@ -0,0 +1,103 @@
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: "sha256-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
async function hashValue(value: string): Promise<string> {
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(value),
)
return [...new Uint8Array(digest)]
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("")
.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 [queryHash, projectIdHash, idsHash, contentHashes] = await Promise.all([
hashValue(query),
containerTag ? hashValue(containerTag) : Promise.resolve(undefined),
Promise.all(results.map((result) => hashValue(result.id))),
Promise.all(results.map((result) => hashValue(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: "sha256-prefix-16",
}
}

View file

@ -6,6 +6,7 @@ import {
RESOURCE_MIME_TYPE,
} from "@modelcontextprotocol/ext-apps/server"
import { SupermemoryClient, getMemoryText } from "./client"
import { createRetrievalReceipt } from "./retrieval-receipt"
import { initPosthog, posthog } from "./posthog"
import { z } from "zod"
import mcpAppHtml from "../dist/mcp-app.html"
@ -86,6 +87,13 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
.max(1000, "Query exceeds maximum length of 1,000 characters")
.describe("The search query to find relevant memories"),
includeProfile: z.boolean().optional().default(true),
includeReceipt: z
.boolean()
.optional()
.default(false)
.describe(
"Include a privacy-safe retrieval receipt with hashed query, result IDs, and content hashes for debugging",
),
...(hasRootContainerTag ? {} : containerTagField),
})
@ -619,9 +627,15 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
private async handleRecall(args: {
query: string
includeProfile?: boolean
includeReceipt?: boolean
containerTag?: string
}) {
const { query, includeProfile = true, containerTag } = args
const {
query,
includeProfile = true,
includeReceipt = false,
containerTag,
} = args
try {
const client = this.getClient(containerTag)
@ -666,12 +680,14 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
}
const endTime = Date.now()
const searchResults = profileResult.searchResults?.results || []
const effectiveContainerTag = containerTag || this.props?.containerTag
// Track search event
posthog
.memorySearch({
query_length: query.length,
results_count: profileResult.searchResults?.results.length || 0,
results_count: searchResults.length,
search_duration_ms: endTime - startTime,
container_tags_count: 1,
source: "mcp",
@ -679,25 +695,50 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
mcp_client_name: clientInfo?.name,
mcp_client_version: clientInfo?.version,
sessionId: this.getMcpSessionId(),
containerTag: containerTag || this.props?.containerTag,
containerTag: effectiveContainerTag,
})
.catch((error) => console.error("PostHog tracking error:", error))
const content = [
{
type: "text" as const,
text:
parts.length > 0
? parts.join("\n")
: "No memories or profile found.",
},
]
return {
content: [
{
type: "text" as const,
text:
parts.length > 0
? parts.join("\n")
: "No memories or profile found.",
},
],
content,
...(includeReceipt
? {
structuredContent: {
receipt: await createRetrievalReceipt({
query,
containerTag: effectiveContainerTag,
clientInfo,
results: searchResults.map((result) => ({
id: result.id,
similarity: result.similarity,
text: getMemoryText(result),
})),
total: profileResult.searchResults?.total,
latencyMs: endTime - startTime,
profile: {
staticCount: profileResult.profile.static.length,
dynamicCount: profileResult.profile.dynamic.length,
},
}),
},
}
: {}),
}
}
const searchResult = await client.search(query, 10)
const endTime = Date.now()
const effectiveContainerTag = containerTag || this.props?.containerTag
// Track search event
posthog
@ -711,13 +752,27 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
mcp_client_name: clientInfo?.name,
mcp_client_version: clientInfo?.version,
sessionId: this.getMcpSessionId(),
containerTag: containerTag || this.props?.containerTag,
containerTag: effectiveContainerTag,
})
.catch((error) => console.error("PostHog tracking error:", error))
if (searchResult.results.length === 0) {
return {
content: [{ type: "text" as const, text: "No memories found." }],
...(includeReceipt
? {
structuredContent: {
receipt: await createRetrievalReceipt({
query,
containerTag: effectiveContainerTag,
clientInfo,
results: [],
total: searchResult.total,
latencyMs: endTime - startTime,
}),
},
}
: {}),
}
}
@ -730,7 +785,27 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
parts.push(getMemoryText(memory))
}
return { content: [{ type: "text" as const, text: parts.join("\n") }] }
return {
content: [{ type: "text" as const, text: parts.join("\n") }],
...(includeReceipt
? {
structuredContent: {
receipt: await createRetrievalReceipt({
query,
containerTag: effectiveContainerTag,
clientInfo,
results: searchResult.results.map((result) => ({
id: result.id,
similarity: result.similarity,
text: getMemoryText(result),
})),
total: searchResult.total,
latencyMs: endTime - startTime,
}),
},
}
: {}),
}
} catch (error) {
const message =
error instanceof Error ? error.message : "An unexpected error occurred"