mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-10-03 02:21:23 +00:00
feat(mcp): add privacy-safe recall receipts
This commit is contained in:
parent
63a90d1f68
commit
8e1a88248a
4 changed files with 243 additions and 8 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
115
apps/mcp/src/receipts.ts
Normal 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)}`
|
||||
}
|
||||
|
|
@ -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
|
||||
> {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue