feat(mcp): add privacy-safe recall receipts

This commit is contained in:
Siddhesh Gawade 2026-06-05 19:23:49 +05:30
parent 63a90d1f68
commit 8e1a88248a
4 changed files with 243 additions and 8 deletions

View file

@ -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

View file

@ -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 = {

115
apps/mcp/src/receipts.ts Normal file
View file

@ -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<string> {
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<MemorySearchReturnedReceipt> {
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)}`
}

View file

@ -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<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 memory.search.returned receipt (hashed fields only)",
),
...(hasRootContainerTag ? {} : containerTagField),
})
@ -619,18 +633,28 @@ 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)
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<Env, unknown, Props> {
}
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<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 (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<Env, unknown, Props> {
}
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<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) {
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<Env, unknown, Props> {
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<Env, unknown, Props> {
}
}
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<MemorySearchReturnedReceipt> {
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
> {