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, },