feat(mcp): playground-style rich recall (formatMemories + profile) (#1101)

## What

Makes the MCP `recall` tool render memories the same way the console **playground** does — rich, scored, relation-aware output — instead of the old flat `### Memory N (NN% match)` list.

The playground pattern is: **profile + rich search → one shared `formatMemories` formatter → feed to the model.** This ports that into the standalone MCP server.

## Changes

- **`format.ts`** (new) — `formatMemories`, lifted verbatim from the playground. Pure, dependency-free. Renders similarity scores, `agg`/`chunk` markers, related-memory arrows (`←` parent, `→` child, `~` related), attached document summaries, and temporal context.
- **`client.ts`** — `SupermemoryClient.search()` now accepts the playground's retrieval knobs (`searchMode` / `rerank` / `rewriteQuery` / `include`) and **stops discarding** the rich response fields (`context`, `documents`, `metadata`, `isAggregated`) during normalization, so the formatter has data to render.
- **`server.ts` (`handleRecall`)** — retrieves memories via `client.search()` with `include: { documents, relatedMemories }`, prepends the user's profile (stable + recent facts) when `includeProfile` is set, and renders memories through `formatMemories`. Output capped at `MAX_RECALL_CHARS`.

## How it maps to the playground

| | Playground | MCP (this PR) |
|---|---|---|
| Profile | injected into its own system prompt (`<user_profile>`) | prepended to `recall` output / exposed via `context` prompt |
| Search | hook pre-fetch **or** tool | `recall` tool (model-triggered) |
| Formatting | `formatMemories` → system prompt | `formatMemories` → `recall` tool result |

## Testing

Verified end-to-end against a local API + local MCP, driven through a real MCP client (and Claude Desktop). `recall` now returns scored memory blocks with `Source:`/`Document:` lines and the legend header. Existing e2e assertions remain compatible (they match `## User Profile` / `## Relevant Memories`, both still emitted).

## Notes

- One deliberate gap vs the playground: `aggregate` — the public SDK's `search.memories` doesn't expose it, so no `agg` synthesis blocks. Relation arrows render only when memories actually have linked parent/child versions.
- Possible follow-up: expose the `include` / `rerank` knobs as `recall` tool params so callers can toggle relations/documents per call.
- Pre-existing `tsc` errors in `server.ts` (dual `@modelcontextprotocol/sdk` install) and `mcp-app.ts` (DOM `lib`) are unrelated to this change — confirmed by an unchanged baseline.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---
**Session Details**
- Session: [View Session](https://supermemory.us1.vorflux.com/agent-sessions/c616fc88-a725-4961-9011-712bce740601)
- Requested by: Sreeram Sreedhar (sreeram@supermemory.com)
- Address comments on this PR. Add `(aside)` to your comment to have me ignore it.
This commit is contained in:
sreedharsreeram 2026-06-12 21:53:58 +00:00
parent 81ae72224f
commit 0ae552037a
3 changed files with 242 additions and 77 deletions

View file

@ -3,21 +3,29 @@ import Supermemory from "supermemory"
const MAX_CHARS = 200000 // ~50k tokens (character-based limit)
const DEFAULT_PROJECT_ID = "sm_project_default"
interface MemoryRichFields {
metadata?: Record<string, unknown> | null
updatedAt?: string
context?: Record<string, unknown>
documents?: Array<Record<string, unknown>>
isAggregated?: boolean
}
export type Memory =
| {
| ({
id: string
memory: string
similarity: number
title?: string
content?: string
}
| {
} & MemoryRichFields)
| ({
id: string
chunk: string
similarity: number
title?: string
content?: string
}
} & MemoryRichFields)
export interface SearchResult {
results: Memory[]
@ -25,6 +33,19 @@ export interface SearchResult {
timing: number
}
export interface SearchOptions {
searchMode?: "memories" | "hybrid" | "documents"
rerank?: boolean
rewriteQuery?: boolean
include?: {
documents?: boolean
relatedMemories?: boolean
summaries?: boolean
chunks?: boolean
forgottenMemories?: boolean
}
}
export interface Profile {
static: string[]
dynamic: string[]
@ -98,7 +119,11 @@ interface SDKResult {
content?: string
similarity: number
title?: string
context?: string
metadata?: Record<string, unknown> | null
updatedAt?: string
context?: Record<string, unknown>
documents?: Array<Record<string, unknown>>
isAggregated?: boolean
}
export class SupermemoryClient {
@ -218,26 +243,32 @@ export class SupermemoryClient {
query: string,
limit = 10,
threshold?: number,
options?: SearchOptions,
): Promise<SearchResult> {
try {
const result = await this.client.search.memories({
q: query,
limit,
containerTag: this.containerTag,
searchMode: "hybrid",
searchMode: options?.searchMode ?? "hybrid",
threshold, // Optional threshold parameter
rerank: options?.rerank,
rewriteQuery: options?.rewriteQuery,
include: options?.include,
})
// Normalize and limit response size — preserve memory vs chunk distinction
const results: Memory[] = (result.results as SDKResult[]).map((r) => {
const text = limitByChars(
r.content || r.memory || r.chunk || r.context || "",
)
const text = limitByChars(r.content || r.memory || r.chunk || "")
const base = {
id: r.id,
similarity: r.similarity,
title: r.title,
content: r.content,
metadata: r.metadata,
updatedAt: r.updatedAt,
context: r.context,
documents: r.documents,
isAggregated: r.isAggregated,
}
if (r.chunk && !r.memory) {
return { ...base, chunk: text }
@ -273,9 +304,7 @@ export class SupermemoryClient {
if (result.searchResults) {
response.searchResults = {
results: (result.searchResults.results as SDKResult[]).map((r) => {
const text = limitByChars(
r.content || r.memory || r.chunk || r.context || "",
)
const text = limitByChars(r.content || r.memory || r.chunk || "")
const base = {
id: r.id,
similarity: r.similarity,

157
apps/mcp/src/format.ts Normal file
View file

@ -0,0 +1,157 @@
export function formatMemories(
response: { results?: Array<Record<string, unknown>>; total?: number },
opts: {
minSimilarity?: number
maxRelations?: number
maxDocuments?: number
maxChunkLength?: number
includeScores?: boolean
includeLegend?: boolean
} = {},
) {
const {
minSimilarity = 0,
maxRelations = 4,
maxDocuments = 3,
maxChunkLength = Number.POSITIVE_INFINITY,
includeScores = true,
includeLegend = true,
} = opts
const day = (s: string | null | undefined) => s?.slice(0, 10) ?? ""
const mime = (m: string | undefined) =>
!m
? ""
: m === "application/pdf"
? "pdf"
: m.includes("spreadsheet")
? "xlsx"
: m.includes("presentation")
? "pptx"
: m.includes("document")
? "doc"
: (m.split("/").pop() ?? "")
const temporal = (tc: Record<string, unknown> | undefined) => {
if (!tc) return [] as string[]
const ev = ((tc.eventDate as string[]) ?? []).map(day).filter(Boolean)
return [
tc.documentDate && `doc ${day(tc.documentDate as string)}`,
ev.length === 1 && `event ${ev[0]}`,
ev.length > 1 && `event ${ev[0]}${ev.at(-1)}`,
].filter(Boolean) as string[]
}
const describeMeta = (m: Record<string, unknown> | undefined | null) => {
if (!m) return ""
const tags = [
mime(m.mimeType as string | undefined),
m.source as string | undefined,
...temporal(m.temporalContext as Record<string, unknown> | undefined),
].filter(Boolean)
return [m.title && `"${m.title}"`, tags.length && `(${tags.join(", ")})`]
.filter(Boolean)
.join(" ")
}
const renderRelations = (
rels: Array<Record<string, unknown>> | undefined,
arrow: string,
root: string,
) => {
if (!rels?.length) return [] as string[]
const seen = new Set<string>()
const items = rels.filter((r) => {
const k = (r.memory as string).trim()
if (k === root.trim() || seen.has(k)) return false
seen.add(k)
return true
})
const shown = items.slice(0, maxRelations)
const lines = shown.map((r) => {
const t = temporal(
(r.metadata as Record<string, unknown> | undefined)?.temporalContext as
| Record<string, unknown>
| undefined,
)
const when = t.length ? t.join(", ") : day(r.updatedAt as string)
return ` ${arrow} ${r.relation}${when ? `, ${when}` : ""}: ${r.memory}`
})
if (items.length > shown.length)
lines.push(` ${arrow} … +${items.length - shown.length} more`)
return lines
}
const renderDocs = (ds: Array<Record<string, unknown>> | undefined) =>
(ds ?? []).slice(0, maxDocuments).map((d) => {
const title = d.title ? `"${d.title}"` : "(untitled)"
const type = d.type ? ` (${d.type})` : ""
const summary = d.summary ? `${d.summary}` : ""
return ` Document: ${title}${type}${summary}`
})
const results = (response.results ?? []).filter(
(m) => ((m.similarity as number) ?? 0) >= minSimilarity,
)
if (!results.length) return "No relevant memories found."
const total = response.total ?? results.length
const header = [
`${results.length} memor${results.length === 1 ? "y" : "ies"}` +
(total !== results.length ? ` of ${total}` : "") +
", ranked by relevance.",
includeLegend &&
"Markers: 'agg' = aggregated synthesis, 'chunk' = raw excerpt; ← parent, → child, ~ related.",
]
.filter(Boolean)
.join(" ")
const arrows = [
["parents", "←"],
["children", "→"],
["related", "~"],
] as const
const blocks = results.map((m) => {
const score = (m.similarity as number)?.toFixed(2) ?? "—"
const prefix = includeScores ? `${score} ` : ""
const memory = (m.memory as string) ?? ""
if (m.isAggregated) return `${prefix}agg ${memory}`
if (m.chunk != null && m.memory == null) {
const body = (m.chunk as string).replace(/\s+$/, "")
const text =
body.length > maxChunkLength
? `${body.slice(0, maxChunkLength)} … [truncated, ${body.length - maxChunkLength} more chars]`
: body
return [
`${prefix}chunk ${describeMeta(m.metadata as Record<string, unknown> | null)}`.trimEnd(),
...renderDocs(
m.documents as Array<Record<string, unknown>> | undefined,
),
...text.split("\n").map((l: string) => ` ${l}`),
].join("\n")
}
const meta = describeMeta(m.metadata as Record<string, unknown> | null)
const ctx = (m.context ?? {}) as Record<
string,
Array<Record<string, unknown>>
>
return [
`${prefix}${memory}`,
meta
? ` Source: ${meta}`
: day(m.updatedAt as string)
? ` Source: updated ${day(m.updatedAt as string)}`
: null,
...renderDocs(m.documents as Array<Record<string, unknown>> | undefined),
...arrows.flatMap(([k, a]) => renderRelations(ctx[k], a, memory)),
]
.filter(Boolean)
.join("\n")
})
return [header, "", blocks.join("\n\n")].join("\n")
}

View file

@ -5,7 +5,8 @@ import {
registerAppResource,
RESOURCE_MIME_TYPE,
} from "@modelcontextprotocol/ext-apps/server"
import { SupermemoryClient, getMemoryText } from "./client"
import { SupermemoryClient } from "./client"
import { formatMemories } from "./format"
import { initPosthog, posthog } from "./posthog"
import { z } from "zod"
import mcpAppHtml from "../dist/mcp-app.html"
@ -26,6 +27,8 @@ type Props = {
const CONTAINER_TAGS_TTL_MS = 5 * 60 * 1000
const MAX_RECALL_CHARS = 200000
export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
private clientInfo: { name: string; version?: string } | null = null
private cachedContainerTags: string[] = []
@ -628,10 +631,21 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
const clientInfo = await this.getClientInfo()
const startTime = Date.now()
if (includeProfile) {
const profileResult = await client.getProfile(query)
const parts: string[] = []
const searchResult = await client.search(query, 10, undefined, {
searchMode: "hybrid",
include: {
documents: true,
relatedMemories: true,
summaries: false,
chunks: false,
forgottenMemories: false,
},
})
const parts: string[] = []
if (includeProfile) {
const profileResult = await client.getProfile()
if (
profileResult.profile.static.length > 0 ||
profileResult.profile.dynamic.length > 0
@ -649,54 +663,23 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
parts.push(`- ${fact}`)
}
}
}
if (profileResult.searchResults?.results.length) {
parts.push("\n## Relevant Memories")
for (const [
i,
memory,
] of profileResult.searchResults.results.entries()) {
parts.push(
`\n### Memory ${i + 1} (${Math.round(memory.similarity * 100)}% match)`,
)
if (memory.title) parts.push(`**${memory.title}**`)
parts.push(getMemoryText(memory))
}
}
const endTime = Date.now()
// Track search event
posthog
.memorySearch({
query_length: query.length,
results_count: profileResult.searchResults?.results.length || 0,
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,
})
.catch((error) => console.error("PostHog tracking error:", error))
return {
content: [
{
type: "text" as const,
text:
parts.length > 0
? parts.join("\n")
: "No memories or profile found.",
},
],
parts.push("")
}
}
const searchResult = await client.search(query, 10)
parts.push("## Relevant Memories")
parts.push(
formatMemories(
{
results: searchResult.results as unknown as Array<
Record<string, unknown>
>,
total: searchResult.total,
},
{ includeScores: true, includeLegend: true },
),
)
const endTime = Date.now()
// Track search event
@ -715,22 +698,18 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
})
.catch((error) => console.error("PostHog tracking error:", error))
if (searchResult.results.length === 0) {
return {
content: [{ type: "text" as const, text: "No memories found." }],
}
const text = parts.join("\n")
return {
content: [
{
type: "text" as const,
text:
text.length > MAX_RECALL_CHARS
? `${text.slice(0, MAX_RECALL_CHARS)}...`
: text,
},
],
}
const parts = ["## Relevant Memories"]
for (const [i, memory] of searchResult.results.entries()) {
parts.push(
`\n### Memory ${i + 1} (${Math.round(memory.similarity * 100)}% match)`,
)
if (memory.title) parts.push(`**${memory.title}**`)
parts.push(getMemoryText(memory))
}
return { content: [{ type: "text" as const, text: parts.join("\n") }] }
} catch (error) {
const message =
error instanceof Error ? error.message : "An unexpected error occurred"