Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
30 changes: 30 additions & 0 deletions apps/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
```
Expand All @@ -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.
Expand Down Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions apps/mcp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
115 changes: 115 additions & 0 deletions apps/mcp/src/receipts.ts
Original file line number Diff line number Diff line change
@@ -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)}`
}
104 changes: 96 additions & 8 deletions apps/mcp/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,22 @@ 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"

type Env = {
MCP_SERVER: DurableObjectNamespace
API_URL?: string
POSTHOG_API_KEY?: string
RECEIPT_MODE?: string
RECEIPT_HASH_SALT?: string
}

type Props = {
Expand Down Expand Up @@ -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),
})

Expand Down Expand Up @@ -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 ||
Expand Down Expand Up @@ -666,23 +690,38 @@ 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",
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))

if (includeReceipt && receipt) {
parts.push("\n## Memory Receipt (Privacy-safe)")
parts.push(JSON.stringify(receipt, null, 2))
}

return {
content: [
{
Expand All @@ -697,39 +736,65 @@ 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",
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))

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)`,
)
if (memory.title) parts.push(`**${memory.title}**`)
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 =
Expand All @@ -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
> {
Expand Down
Loading