Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions apps/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 |

Expand Down
82 changes: 82 additions & 0 deletions apps/mcp/e2e/list-memories.test.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 193 additions & 0 deletions apps/mcp/src/format.test.ts
Original file line number Diff line number Diff line change
@@ -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.")
})
})
53 changes: 53 additions & 0 deletions apps/mcp/src/format.ts
Original file line number Diff line number Diff line change
@@ -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) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

formatMemoriesList truncates each individual entry.memory, but this loop still renders every active memoryEntries item for every document on the page. Since the limit only caps documents, a single document with hundreds or thousands of memories, or a full page of documents with many memories each, can produce unbounded MCP output and large Worker string allocations. Please add an overall response character cap and/or a per-document/page memory cap with a clear truncation message, such as asking the caller to request a narrower scope or later page.

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: {
Expand Down
Loading