feat(mcp): add listMemories tool for enumerating stored memories (#1183)

This commit is contained in:
Abhay Singh 2026-07-11 07:45:12 +05:30 committed by GitHub
parent b49903a493
commit 9487a928f6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 423 additions and 2 deletions

View file

@ -110,6 +110,24 @@ Search memories and get user profile.
| `includeProfile` | boolean | No | Include user profile summary. Default: `true` |
| `containerTag` | string | No | Project tag to scope the search |
### `listMemories`
Enumerate stored memories grouped by their source document, newest first. Returns only the extracted memory facts — never document content — so responses stay small enough for client output limits. Use it to audit what is on file (e.g. before forgetting stale memories); use `recall` for topic-based search.
```json
{
"page": 1,
"limit": 10,
"containerTag": "optional-project-tag"
}
```
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `page` | integer | No | Page number (1-based). Default: `1` |
| `limit` | integer | No | Documents per page, each grouping its extracted memories. Default: `10`, max: `50` |
| `containerTag` | string | No | Project tag to scope the listing |
### `whoAmI`
Get the current logged-in user's information.
@ -190,6 +208,7 @@ bun run test:e2e
| `e2e/oauth.test.ts` | OAuth discovery chain, dynamic client registration, token-endpoint negatives, real refresh→access token round-trip |
| `e2e/discovery.test.ts` | handshake, tools/resources/prompts listing, `whoAmI`, `listProjects` |
| `e2e/memory.test.ts` | save→recall round-trip, profile variants, `forget`, container scoping, bad args |
| `e2e/list-memories.test.ts` | `listMemories` discovery, save→list round-trip, pagination, arg validation |
| `e2e/root-scope.test.ts` | `x-sm-project` header strips the `containerTag` param and scopes the whole connection |
| `e2e/graph.test.ts` | `memory-graph`, `fetch-graph-data`, resource reads, `context` prompt |

View file

@ -0,0 +1,82 @@
import { randomUUID } from "node:crypto"
import { afterAll, beforeAll, describe, expect, it } from "vitest"
import {
API_KEY,
callTool,
connect,
type Session,
sleep,
textOf,
} from "./helpers"
// listMemories reads extracted memory entries, which appear only after the
// async ingestion pipeline finishes — poll like recallUntil does.
async function listUntil(
s: Session,
needle: string,
{ tries = 18, delayMs = 5000 } = {},
): Promise<string | null> {
for (let i = 0; i < tries; i++) {
// The marker document is the newest, so page 1 is enough.
const res = await callTool(s.client, "listMemories", { limit: 20 })
const txt = textOf(res)
if (txt.includes(needle)) return txt
await sleep(delayMs)
}
return null
}
describe.skipIf(!API_KEY)("MCP — listMemories", () => {
let s: Session
const created: string[] = []
beforeAll(async () => {
s = await connect()
})
afterAll(async () => {
for (const content of created) {
await callTool(s.client, "memory", {
content,
action: "forget",
}).catch(() => {})
}
await s?.close()
})
it("appears in tool discovery", async () => {
const tools = await s.client.listTools()
const names = tools.tools.map((t) => t.name)
expect(names).toContain("listMemories")
})
it("lists a saved memory without dumping document content", async () => {
const marker = `lm-${randomUUID()}`
const content = `e2e listMemories. token=${marker}. The list test fruit is rambutan.`
created.push(content)
const save = await callTool(s.client, "memory", { content, action: "save" })
expect(save.isError).toBeFalsy()
const listing = await listUntil(s, marker)
expect(
listing,
`listMemories never returned marker ${marker}`,
).not.toBeNull()
// Header shape: "N memories across M documents (page X of Y, ...)"
expect(listing).toMatch(/memor(y|ies) across \d+ document/)
}, 120_000)
it("paginates with a bounded page size", async () => {
const res = await callTool(s.client, "listMemories", { page: 1, limit: 1 })
expect(res.isError).toBeFalsy()
const txt = textOf(res)
// With the memory saved above there is at least one document.
expect(txt).toMatch(/page 1 of \d+/)
}, 30_000)
it("rejects an out-of-range limit", async () => {
const res = await callTool(s.client, "listMemories", { limit: 500 })
// Zod schema caps limit at 50 — the SDK surfaces this as a tool error.
expect(res.isError).toBeTruthy()
}, 30_000)
})

193
apps/mcp/src/format.test.ts Normal file
View file

@ -0,0 +1,193 @@
import { describe, expect, it } from "vitest"
import type { DocumentsApiResponse } from "./client"
import { formatMemoriesList } from "./format"
function makeResponse(
overrides: Partial<DocumentsApiResponse> = {},
): DocumentsApiResponse {
return {
documents: [],
pagination: { currentPage: 1, limit: 10, totalItems: 0, totalPages: 1 },
...overrides,
}
}
function makeEntry(memory: string, extra: Record<string, unknown> = {}) {
return {
id: `mem_${memory.slice(0, 8)}`,
memory,
spaceId: "space_1",
createdAt: "2026-06-10T12:00:00Z",
updatedAt: "2026-06-10T12:00:00Z",
...extra,
}
}
describe("formatMemoriesList", () => {
it("reports an empty store", () => {
expect(formatMemoriesList(makeResponse())).toBe("No memories stored yet.")
})
it("reports an out-of-range page distinctly from an empty store", () => {
const result = formatMemoriesList(
makeResponse({
pagination: {
currentPage: 3,
limit: 10,
totalItems: 12,
totalPages: 2,
},
}),
)
expect(result).toBe("No documents on page 3 (2 pages total).")
})
it("groups memories under their source document with title, type, and date", () => {
const result = formatMemoriesList(
makeResponse({
documents: [
{
id: "doc_1",
title: "Preferences",
type: "text",
createdAt: "2026-06-12T08:00:00Z",
updatedAt: "2026-06-12T08:00:00Z",
memoryEntries: [
makeEntry("User prefers dark mode"),
makeEntry("User works in TypeScript"),
],
},
],
pagination: { currentPage: 1, limit: 10, totalItems: 1, totalPages: 1 },
}),
)
expect(result).toContain(
"2 memories across 1 document (page 1 of 1, 1 documents total), newest first.",
)
expect(result).toContain('"Preferences" (text, 2026-06-12)')
expect(result).toContain("- User prefers dark mode")
expect(result).toContain("- User works in TypeScript")
expect(result).not.toContain("More available")
})
it("excludes forgotten and superseded memory entries", () => {
const result = formatMemoriesList(
makeResponse({
documents: [
{
id: "doc_1",
title: "Facts",
type: "text",
createdAt: "2026-06-12T08:00:00Z",
updatedAt: "2026-06-12T08:00:00Z",
memoryEntries: [
makeEntry("Current fact"),
makeEntry("Forgotten fact", { isForgotten: true }),
makeEntry("Old version of a fact", { isLatest: false }),
],
},
],
pagination: { currentPage: 1, limit: 10, totalItems: 1, totalPages: 1 },
}),
)
expect(result).toContain("- Current fact")
expect(result).not.toContain("Forgotten fact")
expect(result).not.toContain("Old version of a fact")
expect(result).toContain("1 memory across 1 document")
})
it("marks documents whose extraction has not produced memories yet", () => {
const result = formatMemoriesList(
makeResponse({
documents: [
{
id: "doc_1",
title: "Still processing",
type: "text",
createdAt: "2026-06-12T08:00:00Z",
updatedAt: "2026-06-12T08:00:00Z",
memoryEntries: [],
},
],
pagination: { currentPage: 1, limit: 10, totalItems: 1, totalPages: 1 },
}),
)
expect(result).toContain(
'"Still processing" (text, 2026-06-12) — no extracted memories yet',
)
})
it("falls back to (untitled) for documents without a title", () => {
const result = formatMemoriesList(
makeResponse({
documents: [
{
id: "doc_1",
title: null,
type: "text",
createdAt: "2026-06-12T08:00:00Z",
updatedAt: "2026-06-12T08:00:00Z",
memoryEntries: [makeEntry("Some fact")],
},
],
pagination: { currentPage: 1, limit: 10, totalItems: 1, totalPages: 1 },
}),
)
expect(result).toContain('"(untitled)" (text, 2026-06-12)')
})
it("flattens multi-line memories and truncates oversized ones", () => {
const longMemory = `start ${"x".repeat(600)}`
const result = formatMemoriesList(
makeResponse({
documents: [
{
id: "doc_1",
title: "Big",
type: "text",
createdAt: "2026-06-12T08:00:00Z",
updatedAt: "2026-06-12T08:00:00Z",
memoryEntries: [
makeEntry("line one\nline two\ttabbed"),
makeEntry(longMemory),
],
},
],
pagination: { currentPage: 1, limit: 10, totalItems: 1, totalPages: 1 },
}),
)
expect(result).toContain("- line one line two tabbed")
expect(result).toContain("… [truncated]")
const truncatedLine = result
.split("\n")
.find((line) => line.includes("[truncated]"))
expect(truncatedLine).toBeDefined()
expect((truncatedLine as string).length).toBeLessThan(600)
})
it("points at the next page when more documents exist", () => {
const result = formatMemoriesList(
makeResponse({
documents: [
{
id: "doc_1",
title: "Page one doc",
type: "text",
createdAt: "2026-06-12T08:00:00Z",
updatedAt: "2026-06-12T08:00:00Z",
memoryEntries: [makeEntry("A fact")],
},
],
pagination: { currentPage: 1, limit: 1, totalItems: 3, totalPages: 3 },
}),
)
expect(result).toContain("page 1 of 3, 3 documents total")
expect(result).toContain("More available — call listMemories with page: 2.")
})
})

View file

@ -1,3 +1,56 @@
import type { DocumentsApiResponse } from "./client"
// Listing must stay lightweight: memory entries are extracted facts (short
// strings), never raw document content, so responses fit comfortably in
// client output limits even at the maximum page size.
const MAX_LIST_MEMORY_CHARS = 500
export function formatMemoriesList(response: DocumentsApiResponse): string {
const { documents, pagination } = response
const day = (s: string | null | undefined) => s?.slice(0, 10) ?? ""
if (documents.length === 0) {
return pagination.currentPage > 1
? `No documents on page ${pagination.currentPage} (${pagination.totalPages} page${pagination.totalPages === 1 ? "" : "s"} total).`
: "No memories stored yet."
}
let memoryCount = 0
const blocks = documents.map((doc) => {
const activeEntries = doc.memoryEntries.filter(
(entry) => entry.isForgotten !== true && entry.isLatest !== false,
)
const title = doc.title?.trim() || "(untitled)"
const header = `"${title}" (${doc.type}, ${day(doc.createdAt)})`
if (activeEntries.length === 0) {
return `${header} — no extracted memories yet`
}
memoryCount += activeEntries.length
const lines = activeEntries.map((entry) => {
const text = entry.memory.replace(/\s+/g, " ").trim()
return `- ${
text.length > MAX_LIST_MEMORY_CHARS
? `${text.slice(0, MAX_LIST_MEMORY_CHARS)} … [truncated]`
: text
}`
})
return [header, ...lines].join("\n")
})
const header = `${memoryCount} memor${memoryCount === 1 ? "y" : "ies"} across ${documents.length} document${documents.length === 1 ? "" : "s"} (page ${pagination.currentPage} of ${pagination.totalPages}, ${pagination.totalItems} documents total), newest first.`
const parts = [header, "", blocks.join("\n\n")]
if (pagination.currentPage < pagination.totalPages) {
parts.push(
"",
`More available — call listMemories with page: ${pagination.currentPage + 1}.`,
)
}
return parts.join("\n")
}
export function formatMemories(
response: { results?: Array<Record<string, unknown>>; total?: number },
opts: {

View file

@ -6,7 +6,7 @@ import {
RESOURCE_MIME_TYPE,
} from "@modelcontextprotocol/ext-apps/server"
import { SupermemoryClient } from "./client"
import { formatMemories } from "./format"
import { formatMemories, formatMemoriesList } from "./format"
import { initPosthog, posthog } from "./posthog"
import { z } from "zod"
import mcpAppHtml from "../dist/mcp-app.html"
@ -92,6 +92,27 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
...(hasRootContainerTag ? {} : containerTagField),
})
const listMemoriesSchema = z.object({
page: z
.number()
.int()
.min(1)
.optional()
.default(1)
.describe("Page number (1-based)"),
limit: z
.number()
.int()
.min(1)
.max(50)
.optional()
.default(10)
.describe(
"Documents per page; each document groups its extracted memories (default 10, max 50)",
),
...(hasRootContainerTag ? {} : containerTagField),
})
const contextPromptSchema = z.object({
includeRecent: z
.boolean()
@ -104,6 +125,7 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
type ContextPromptArgs = z.infer<typeof contextPromptSchema>
type MemoryArgs = z.infer<typeof memorySchema>
type RecallArgs = z.infer<typeof recallSchema>
type ListMemoriesArgs = z.infer<typeof listMemoriesSchema>
// Register memory tool
this.server.registerTool(
@ -129,6 +151,18 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
(args: RecallArgs) => this.handleRecall(args),
)
// Register listMemories tool
this.server.registerTool(
"listMemories",
{
description:
"Enumerate stored memories grouped by their source document, newest first. Returns only the extracted memory facts (no document content), so use it to audit what is on file — e.g. before forgetting stale memories or to power a 'list everything' view. For finding memories relevant to a topic, use 'recall' instead.",
inputSchema: listMemoriesSchema,
},
// @ts-expect-error - zod type inference issue with MCP SDK
(args: ListMemoriesArgs) => this.handleListMemories(args),
)
// Register profile resource
this.server.registerResource(
"User Profile",
@ -726,6 +760,46 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
}
}
private async handleListMemories(args: {
page?: number
limit?: number
containerTag?: string
}) {
const { page = 1, limit = 10, containerTag } = args
const effectiveContainerTag = containerTag || this.props?.containerTag
try {
const client = this.getClient(effectiveContainerTag)
const result = await client.getDocuments(
effectiveContainerTag ? [effectiveContainerTag] : undefined,
page,
limit,
)
return {
content: [
{
type: "text" as const,
text: formatMemoriesList(result),
},
],
}
} catch (error) {
const message =
error instanceof Error ? error.message : "An unexpected error occurred"
console.error("List memories operation failed:", error)
return {
content: [
{
type: "text" as const,
text: `Error listing memories: ${message}`,
},
],
isError: true,
}
}
}
private async getClientInfo(): Promise<
{ name: string; version?: string } | undefined
> {

View file

@ -2,7 +2,7 @@ import { defineConfig } from "vitest/config"
export default defineConfig({
test: {
include: ["e2e/**/*.test.ts"],
include: ["e2e/**/*.test.ts", "src/**/*.test.ts"],
testTimeout: 90_000,
hookTimeout: 30_000,
},