Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
58 changes: 58 additions & 0 deletions apps/mcp/src/retrieval-receipt.test.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 103 additions & 0 deletions apps/mcp/src/retrieval-receipt.ts
Original file line number Diff line number Diff line change
@@ -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",
}
}
103 changes: 89 additions & 14 deletions apps/mcp/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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),
})

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -666,38 +680,65 @@ 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",
userId: this.props?.userId || "unknown",
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
Expand All @@ -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,
}),
},
}
: {}),
}
}

Expand All @@ -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"
Expand Down