mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
feat(mcp): add listMemories tool for enumerating stored memories (#1183)
This commit is contained in:
parent
b49903a493
commit
9487a928f6
6 changed files with 423 additions and 2 deletions
|
|
@ -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 |
|
||||
|
||||
|
|
|
|||
82
apps/mcp/e2e/list-memories.test.ts
Normal file
82
apps/mcp/e2e/list-memories.test.ts
Normal 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
193
apps/mcp/src/format.test.ts
Normal 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.")
|
||||
})
|
||||
})
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
> {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue