diff --git a/apps/mcp/src/retrieval-receipt.test.ts b/apps/mcp/src/retrieval-receipt.test.ts new file mode 100644 index 00000000..c1f06f17 --- /dev/null +++ b/apps/mcp/src/retrieval-receipt.test.ts @@ -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") + }) +}) diff --git a/apps/mcp/src/retrieval-receipt.ts b/apps/mcp/src/retrieval-receipt.ts new file mode 100644 index 00000000..54b2639c --- /dev/null +++ b/apps/mcp/src/retrieval-receipt.ts @@ -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 { + 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 { + 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", + } +} diff --git a/apps/mcp/src/server.ts b/apps/mcp/src/server.ts index 510c1481..896b6e71 100644 --- a/apps/mcp/src/server.ts +++ b/apps/mcp/src/server.ts @@ -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 { .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 { 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 { } 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 { 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 { 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 { 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"