From 716d04d6e3d03446ef4036b9aa71a8a5f0bff61f Mon Sep 17 00:00:00 2001 From: abhay-codes07 Date: Thu, 2 Jul 2026 06:54:03 +0530 Subject: [PATCH] feat(mcp): add listMemories tool for enumerating stored memories The MCP server had no way to enumerate what is stored - only top-K recall results, or save/forget one at a time. This blocked audit and cleanup workflows (list everything on file, prune stale memories). Add a listMemories tool that pages through documents via the existing getDocuments client method and returns only the extracted memory facts, grouped by source document, newest first. Document content is never included and each fact is capped at 500 chars, keeping responses well within client output limits (the concern that stalled the previous attempt in #1044). Forgotten and superseded memory entries are filtered out. Pagination is page/limit based (limit capped at 50 documents) to match the underlying documents API, with a next-page hint in the output. Testing: unit tests for the formatter (vitest config now also picks up src/**/*.test.ts), a key-gated e2e spec following the existing suite conventions, and README docs for the new tool. Fixes #1030 --- apps/mcp/README.md | 19 +++ apps/mcp/e2e/list-memories.test.ts | 82 ++++++++++++ apps/mcp/src/format.test.ts | 193 +++++++++++++++++++++++++++++ apps/mcp/src/format.ts | 53 ++++++++ apps/mcp/src/server.ts | 76 +++++++++++- apps/mcp/vitest.config.ts | 2 +- 6 files changed, 423 insertions(+), 2 deletions(-) create mode 100644 apps/mcp/e2e/list-memories.test.ts create mode 100644 apps/mcp/src/format.test.ts diff --git a/apps/mcp/README.md b/apps/mcp/README.md index 761c8ac12..ad396af97 100644 --- a/apps/mcp/README.md +++ b/apps/mcp/README.md @@ -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 | diff --git a/apps/mcp/e2e/list-memories.test.ts b/apps/mcp/e2e/list-memories.test.ts new file mode 100644 index 000000000..bb7a66ade --- /dev/null +++ b/apps/mcp/e2e/list-memories.test.ts @@ -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 { + 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) +}) diff --git a/apps/mcp/src/format.test.ts b/apps/mcp/src/format.test.ts new file mode 100644 index 000000000..3512b20ea --- /dev/null +++ b/apps/mcp/src/format.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from "vitest" +import type { DocumentsApiResponse } from "./client" +import { formatMemoriesList } from "./format" + +function makeResponse( + overrides: Partial = {}, +): DocumentsApiResponse { + return { + documents: [], + pagination: { currentPage: 1, limit: 10, totalItems: 0, totalPages: 1 }, + ...overrides, + } +} + +function makeEntry(memory: string, extra: Record = {}) { + 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.") + }) +}) diff --git a/apps/mcp/src/format.ts b/apps/mcp/src/format.ts index cbd074cf9..43c427f03 100644 --- a/apps/mcp/src/format.ts +++ b/apps/mcp/src/format.ts @@ -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>; total?: number }, opts: { diff --git a/apps/mcp/src/server.ts b/apps/mcp/src/server.ts index de54deff2..6a3363d70 100644 --- a/apps/mcp/src/server.ts +++ b/apps/mcp/src/server.ts @@ -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 { ...(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 { type ContextPromptArgs = z.infer type MemoryArgs = z.infer type RecallArgs = z.infer + type ListMemoriesArgs = z.infer // Register memory tool this.server.registerTool( @@ -129,6 +151,18 @@ export class SupermemoryMCP extends McpAgent { (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 { } } + 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 > { diff --git a/apps/mcp/vitest.config.ts b/apps/mcp/vitest.config.ts index 8289ccd9a..b22e63866 100644 --- a/apps/mcp/vitest.config.ts +++ b/apps/mcp/vitest.config.ts @@ -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, },