mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-10-01 02:01:40 +00:00
feat(mcp): add privacy-safe recall receipts
This commit is contained in:
parent
2684990688
commit
3ac6575db0
3 changed files with 250 additions and 14 deletions
58
apps/mcp/src/retrieval-receipt.test.ts
Normal file
58
apps/mcp/src/retrieval-receipt.test.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
103
apps/mcp/src/retrieval-receipt.ts
Normal file
103
apps/mcp/src/retrieval-receipt.ts
Normal 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",
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue