From 8e1a88248a0cad0aae3d325d7ec1efdefe298e48 Mon Sep 17 00:00:00 2001 From: Siddhesh Gawade Date: Fri, 5 Jun 2026 19:23:49 +0530 Subject: [PATCH] feat(mcp): add privacy-safe recall receipts --- apps/mcp/README.md | 30 ++++++++++ apps/mcp/src/index.ts | 2 + apps/mcp/src/receipts.ts | 115 +++++++++++++++++++++++++++++++++++++++ apps/mcp/src/server.ts | 104 ++++++++++++++++++++++++++++++++--- 4 files changed, 243 insertions(+), 8 deletions(-) create mode 100644 apps/mcp/src/receipts.ts diff --git a/apps/mcp/README.md b/apps/mcp/README.md index 6578939c..7b6401a3 100644 --- a/apps/mcp/README.md +++ b/apps/mcp/README.md @@ -100,6 +100,7 @@ Search memories and get user profile. { "query": "What are the user's programming preferences?", "includeProfile": true, + "includeReceipt": false, "containerTag": "optional-project-tag" } ``` @@ -108,8 +109,35 @@ Search memories and get user profile. |-----------|------|----------|-------------| | `query` | string | Yes | Search query to find relevant memories | | `includeProfile` | boolean | No | Include user profile summary. Default: `true` | +| `includeReceipt` | boolean | No | Include a privacy-safe `memory.search.returned` receipt in tool output. Default: `false` | | `containerTag` | string | No | Project tag to scope the search | +### Privacy-Safe Receipts (Optional) + +The MCP server can emit anonymized recall receipts for debugging without exposing raw memory text or raw queries. + +- Event emitted: `memory.search.returned` +- Privacy controls: + - `query.hash` instead of raw query + - `project.id_hash` instead of raw project id + - `result.content_hash[]` instead of memory bodies + - `result.ids_hash` and `result.score_bucket[]` for retrieval diagnostics + +Enable server-side receipt logging via env vars: + +```env +RECEIPT_MODE=log +RECEIPT_HASH_SALT=optional-secret-salt +``` + +When `RECEIPT_MODE=log`, receipts are emitted to stderr as: + +```text +[SUPERMEMORY_RECEIPT] { ...receipt json... } +``` + +You can also request one receipt inline per recall call using `includeReceipt: true`. + ### `whoAmI` Get the current logged-in user's information. @@ -159,6 +187,8 @@ API_URL=https://api.supermemory.ai | Variable | Description | Default | |----------|-------------|---------| | `API_URL` | Main Supermemory API URL for OAuth validation | `https://api.supermemory.ai` | +| `RECEIPT_MODE` | Receipt mode: `off` or `log` | `off` | +| `RECEIPT_HASH_SALT` | Optional salt used for receipt hashing | _empty_ | ### Run Locally diff --git a/apps/mcp/src/index.ts b/apps/mcp/src/index.ts index 846064e1..991410b0 100644 --- a/apps/mcp/src/index.ts +++ b/apps/mcp/src/index.ts @@ -10,6 +10,8 @@ type Bindings = { API_URL?: string MCP_URL?: string POSTHOG_API_KEY?: string + RECEIPT_MODE?: string + RECEIPT_HASH_SALT?: string } type Props = { diff --git a/apps/mcp/src/receipts.ts b/apps/mcp/src/receipts.ts new file mode 100644 index 00000000..8d85afe7 --- /dev/null +++ b/apps/mcp/src/receipts.ts @@ -0,0 +1,115 @@ +import type { Memory } from "./client" + +const RECEIPT_HASH_LENGTH = 16 + +export type MemorySearchReturnedReceipt = { + event: "memory.search.returned" + memory: { + provider: "supermemory" + } + client: { + name: string + version?: string + } + project: { + id_hash: string + } + query: { + hash: string + } + result: { + count: number + ids_hash: string + score_bucket: number[] + content_hash: string[] + } + snapshot: { + id_hash: string + } + latency_ms: number + timestamp: string +} + +const toHex = (bytes: Uint8Array): string => + Array.from(bytes) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join("") + +export async function privacyHash(input: string, salt = ""): Promise { + const encoder = new TextEncoder() + const hash = await crypto.subtle.digest( + "SHA-256", + encoder.encode(`${salt}:${input}`), + ) + return toHex(new Uint8Array(hash)).slice(0, RECEIPT_HASH_LENGTH) +} + +export function scoreToBucket(score: number): number { + if (!Number.isFinite(score)) { + return 0 + } + const bucket = Math.floor(Math.max(0, Math.min(1, score)) * 10) / 10 + return Number(bucket.toFixed(1)) +} + +export async function buildMemorySearchReceipt(args: { + query: string + projectId?: string + clientName?: string + clientVersion?: string + snapshotId?: string + latencyMs: number + results: Memory[] + hashSalt?: string +}): Promise { + const { + query, + projectId, + clientName, + clientVersion, + snapshotId, + latencyMs, + results, + hashSalt, + } = args + + const idMaterial = results.map((result) => result.id).join("|") + const contentHashes = await Promise.all( + results.map((result) => { + const content = "memory" in result ? result.memory : result.chunk + return privacyHash(content || "", hashSalt) + }), + ) + + return { + event: "memory.search.returned", + memory: { provider: "supermemory" }, + client: { + name: clientName || "unknown", + version: clientVersion, + }, + project: { + id_hash: await privacyHash(projectId || "default", hashSalt), + }, + query: { + hash: await privacyHash(query, hashSalt), + }, + result: { + count: results.length, + ids_hash: await privacyHash(idMaterial, hashSalt), + score_bucket: results.map((result) => scoreToBucket(result.similarity)), + content_hash: contentHashes, + }, + snapshot: { + id_hash: await privacyHash(snapshotId || "unknown", hashSalt), + }, + latency_ms: latencyMs, + timestamp: new Date().toISOString(), + } +} + +export function formatReceiptLogLine( + receipt: MemorySearchReturnedReceipt, +): string { + return `[SUPERMEMORY_RECEIPT] ${JSON.stringify(receipt)}` +} diff --git a/apps/mcp/src/server.ts b/apps/mcp/src/server.ts index 510c1481..75473cff 100644 --- a/apps/mcp/src/server.ts +++ b/apps/mcp/src/server.ts @@ -5,8 +5,13 @@ import { registerAppResource, RESOURCE_MIME_TYPE, } from "@modelcontextprotocol/ext-apps/server" -import { SupermemoryClient, getMemoryText } from "./client" +import { SupermemoryClient, getMemoryText, type Memory } from "./client" import { initPosthog, posthog } from "./posthog" +import { + buildMemorySearchReceipt, + formatReceiptLogLine, + type MemorySearchReturnedReceipt, +} from "./receipts" import { z } from "zod" import mcpAppHtml from "../dist/mcp-app.html" @@ -14,6 +19,8 @@ type Env = { MCP_SERVER: DurableObjectNamespace API_URL?: string POSTHOG_API_KEY?: string + RECEIPT_MODE?: string + RECEIPT_HASH_SALT?: string } type Props = { @@ -86,6 +93,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 memory.search.returned receipt (hashed fields only)", + ), ...(hasRootContainerTag ? {} : containerTagField), }) @@ -619,18 +633,28 @@ 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) const clientInfo = await this.getClientInfo() const startTime = Date.now() + const effectiveContainerTag = containerTag || this.props?.containerTag + let recallResults: Memory[] = [] + let receipt: MemorySearchReturnedReceipt | null = null if (includeProfile) { const profileResult = await client.getProfile(query) const parts: string[] = [] + recallResults = profileResult.searchResults?.results || [] if ( profileResult.profile.static.length > 0 || @@ -666,12 +690,22 @@ export class SupermemoryMCP extends McpAgent { } const endTime = Date.now() + receipt = await this.buildRecallReceipt({ + query, + results: recallResults, + containerTag: effectiveContainerTag, + clientInfo, + latencyMs: endTime - startTime, + }) + if (receipt && this.shouldLogReceipt()) { + console.error(formatReceiptLogLine(receipt)) + } // Track search event posthog .memorySearch({ query_length: query.length, - results_count: profileResult.searchResults?.results.length || 0, + results_count: recallResults.length, search_duration_ms: endTime - startTime, container_tags_count: 1, source: "mcp", @@ -679,10 +713,15 @@ 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 (includeReceipt && receipt) { + parts.push("\n## Memory Receipt (Privacy-safe)") + parts.push(JSON.stringify(receipt, null, 2)) + } + return { content: [ { @@ -697,13 +736,24 @@ export class SupermemoryMCP extends McpAgent { } const searchResult = await client.search(query, 10) + recallResults = searchResult.results const endTime = Date.now() + receipt = await this.buildRecallReceipt({ + query, + results: recallResults, + containerTag: effectiveContainerTag, + clientInfo, + latencyMs: endTime - startTime, + }) + if (receipt && this.shouldLogReceipt()) { + console.error(formatReceiptLogLine(receipt)) + } // Track search event posthog .memorySearch({ query_length: query.length, - results_count: searchResult.results.length, + results_count: recallResults.length, search_duration_ms: endTime - startTime, container_tags_count: 1, source: "mcp", @@ -711,18 +761,28 @@ 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) { + if (recallResults.length === 0) { + if (includeReceipt && receipt) { + return { + content: [ + { + type: "text" as const, + text: `No memories found.\n\n## Memory Receipt (Privacy-safe)\n${JSON.stringify(receipt, null, 2)}`, + }, + ], + } + } return { content: [{ type: "text" as const, text: "No memories found." }], } } const parts = ["## Relevant Memories"] - for (const [i, memory] of searchResult.results.entries()) { + for (const [i, memory] of recallResults.entries()) { parts.push( `\n### Memory ${i + 1} (${Math.round(memory.similarity * 100)}% match)`, ) @@ -730,6 +790,11 @@ export class SupermemoryMCP extends McpAgent { parts.push(getMemoryText(memory)) } + if (includeReceipt && receipt) { + parts.push("\n## Memory Receipt (Privacy-safe)") + parts.push(JSON.stringify(receipt, null, 2)) + } + return { content: [{ type: "text" as const, text: parts.join("\n") }] } } catch (error) { const message = @@ -747,6 +812,29 @@ export class SupermemoryMCP extends McpAgent { } } + private shouldLogReceipt(): boolean { + return (this.env.RECEIPT_MODE || "off").toLowerCase() === "log" + } + + private async buildRecallReceipt(args: { + query: string + results: Memory[] + containerTag?: string + clientInfo?: { name: string; version?: string } + latencyMs: number + }): Promise { + return buildMemorySearchReceipt({ + query: args.query, + results: args.results, + projectId: args.containerTag, + clientName: args.clientInfo?.name, + clientVersion: args.clientInfo?.version, + snapshotId: this.getMcpSessionId(), + latencyMs: args.latencyMs, + hashSalt: this.env.RECEIPT_HASH_SALT, + }) + } + private async getClientInfo(): Promise< { name: string; version?: string } | undefined > {