split mcp documents and memories

This commit is contained in:
Prasanna A P 2026-07-29 18:30:10 -07:00
parent f85366c847
commit 0437c09fdd
19 changed files with 685 additions and 227 deletions

View file

@ -49,9 +49,11 @@ The client discovers the OAuth authorization server through
| Tool | Purpose |
| --- | --- |
| `search_memory` | Search memories and optionally include profile context |
| `listMemories` | List extracted memories grouped by source document |
| `listDocuments` | List document metadata and summaries in a workspace |
| `getDocument` | Read one document's available content by ID |
| `listMemories` | List extracted memory entries and their source document IDs |
| `listSpaces` | List workspaces visible to the authenticated account |
| `whoAmI` | Return identity, access, client, and active-workspace context |
| `whoAmI` | Return identity, access, and active-workspace context |
| `add_memory` | Save or forget a memory |
### MCP App launchers

View file

@ -9,16 +9,28 @@ import {
const EXPECTED_TOOLS = [
"add_memory",
"search_memory",
"fetch-graph-data",
"getDocument",
"guided-save",
"listDocuments",
"listMemories",
"listSpaces",
"whoAmI",
"memory-graph",
"save-memory",
"search_memory",
"select-workspace",
"set-active-tag",
"upload-file",
"upload-file-submit",
"whoAmI",
]
const describeWithAuth = describe.skipIf(!OAUTH_CREDENTIALS_AVAILABLE)
const READ_ONLY_TOOL_NAMES = [
"search_memory",
"listDocuments",
"listMemories",
"getDocument",
"listSpaces",
"whoAmI",
"memory-graph",
@ -50,8 +62,8 @@ describeWithAuth("MCP — discovery & identity", () => {
it("handshakes and lists the expected tools", async () => {
const { tools } = await s.client.listTools()
const names = tools.map((t) => t.name)
for (const t of EXPECTED_TOOLS) expect(names).toContain(t)
const names = tools.map((t) => t.name).sort()
expect(names).toEqual([...EXPECTED_TOOLS].sort())
})
it("marks read-only tools as non-destructive", async () => {

View file

@ -21,7 +21,9 @@ describeWithAuth("MCP — graph, resources & prompts", () => {
it("memory-graph returns a summary + structured documents", async () => {
const res = await callTool(s.client, "memory-graph")
expect(res.isError).toBeFalsy()
expect(textOf(res)).toMatch(/Memory Graph: \d+ documents/)
expect(textOf(res)).toMatch(
/Rendered the interactive Memory Graph MCP App: \d+ documents/,
)
const sc = res.structuredContent as {
documents?: unknown[]
totalCount?: number

View file

@ -7,41 +7,55 @@ import {
textOf,
} from "./helpers"
describe.skipIf(!OAUTH_CREDENTIALS_AVAILABLE)("MCP — listMemories", () => {
let s: Session
describe.skipIf(!OAUTH_CREDENTIALS_AVAILABLE)(
"MCP - documents and memories",
() => {
let s: Session
beforeAll(async () => {
s = await connect()
})
afterAll(async () => {
await s?.close()
})
beforeAll(async () => {
s = await connect()
})
afterAll(async () => {
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("appears in tool discovery", async () => {
const tools = await s.client.listTools()
const names = tools.tools.map((t) => t.name)
expect(names).toContain("listMemories")
expect(names).toContain("listDocuments")
expect(names).toContain("getDocument")
})
it("lists extracted memories without requiring ingestion timing", async () => {
const result = await callTool(s.client, "listMemories", { limit: 20 })
expect(result.isError).toBeFalsy()
expect(textOf(result)).toMatch(
/memor(y|ies) across \d+ document|No memories stored yet/i,
)
})
it("lists extracted memory entries directly", async () => {
const result = await callTool(s.client, "listMemories", { limit: 20 })
expect(result.isError).toBeFalsy()
expect(textOf(result)).toMatch(
/active memor(y|ies) \(page \d+ of \d+|No active memories stored yet/i,
)
})
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)
// Empty accounts still return a page header; populated ones include page info.
expect(txt).toMatch(/page 1 of \d+|No memories stored yet/i)
}, 30_000)
it("lists documents and can read one by ID", async () => {
const res = await callTool(s.client, "listDocuments", {
page: 1,
limit: 1,
})
expect(res.isError).toBeFalsy()
const txt = textOf(res)
expect(txt).toMatch(/page 1 of \d+|No documents stored yet/i)
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)
})
const documentId = txt.match(/- \[([^\]]+)\]/)?.[1]
if (!documentId) return
const document = await callTool(s.client, "getDocument", { documentId })
expect(document.isError).toBeFalsy()
expect(textOf(document)).toContain(`Document ID: ${documentId}`)
}, 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)
},
)

View file

@ -15,7 +15,7 @@
"studio": "vite --config vite.config.dev.ts --open /studio.html",
"deploy": "vite build && wrangler deploy --minify",
"check-types": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.widget.json",
"test:unit": "vitest run src/server src/widget",
"test:unit": "vitest run src",
"test:e2e": "vitest run e2e",
"cf-typegen": "wrangler types --env-interface CloudflareBindings"
},

View file

@ -1,10 +1,18 @@
import { describe, expect, it } from "vitest"
import type { DocumentsApiResponse } from "./server/client"
import { formatMemoriesList } from "./server/format"
import type {
DocumentDetails,
DocumentsListResponse,
MemoryEntriesResponse,
} from "./server/client"
import {
formatDocument,
formatDocumentsList,
formatMemoryEntriesList,
} from "./server/format"
function makeResponse(
overrides: Partial<DocumentsApiResponse> = {},
): DocumentsApiResponse {
function makeDocumentsResponse(
overrides: Partial<DocumentsListResponse> = {},
): DocumentsListResponse {
return {
documents: [],
pagination: { currentPage: 1, limit: 10, totalItems: 0, totalPages: 1 },
@ -12,50 +20,51 @@ function makeResponse(
}
}
function makeEntry(memory: string, extra: Record<string, unknown> = {}) {
function makeMemoryResponse(
overrides: Partial<MemoryEntriesResponse> = {},
): MemoryEntriesResponse {
return {
memoryEntries: [],
pagination: { currentPage: 1, limit: 10, totalItems: 0, totalPages: 1 },
...overrides,
}
}
function makeMemory(memory: string, extra: Record<string, unknown> = {}) {
return {
id: `mem_${memory.slice(0, 8)}`,
memory,
spaceId: "space_1",
version: 1,
isLatest: true,
isForgotten: false,
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,
},
}),
describe("formatDocumentsList", () => {
it("reports an empty document store", () => {
expect(formatDocumentsList(makeDocumentsResponse())).toBe(
"No documents stored yet.",
)
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({
it("formats document metadata and stable IDs without content", () => {
const result = formatDocumentsList(
makeDocumentsResponse({
documents: [
{
id: "doc_1",
connectionId: null,
createdAt: "2026-06-12T08:00:00Z",
customId: null,
metadata: null,
status: "done",
summary: "A compact summary.",
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 },
@ -63,131 +72,148 @@ describe("formatMemoriesList", () => {
)
expect(result).toContain(
"2 memories across 1 document (page 1 of 1, 1 documents total), newest first.",
"1 document (page 1 of 1, 1 document 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('- [doc_1] "Preferences" (text, done, 2026-06-12)')
expect(result).toContain("Summary: A compact summary.")
expect(result).toContain(
'"Still processing" (text, 2026-06-12) - no extracted memories yet',
"Use getDocument with a document ID to read its content.",
)
})
it("falls back to (untitled) for documents without a title", () => {
const result = formatMemoriesList(
makeResponse({
it("points to the next document page", () => {
const result = formatDocumentsList(
makeDocumentsResponse({
documents: [
{
id: "doc_1",
connectionId: null,
createdAt: "2026-06-12T08:00:00Z",
customId: null,
metadata: null,
status: "done",
summary: null,
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.")
expect(result).toContain('"(untitled)"')
expect(result).toContain(
"More available - call listDocuments with page: 2.",
)
})
})
describe("formatMemoryEntriesList", () => {
it("reports an empty memory store", () => {
expect(formatMemoryEntriesList(makeMemoryResponse())).toBe(
"No active memories stored yet.",
)
})
it("formats active memories independently of documents", () => {
const result = formatMemoryEntriesList(
makeMemoryResponse({
memoryEntries: [
makeMemory("User prefers dark mode", {
id: "mem_1",
version: 2,
documentIds: ["doc_1", "doc_2"],
history: [
{
id: "mem_old",
memory: "User sometimes uses dark mode",
version: 1,
createdAt: "2026-06-01T00:00:00Z",
updatedAt: "2026-06-01T00:00:00Z",
},
],
}),
],
pagination: { currentPage: 1, limit: 10, totalItems: 1, totalPages: 1 },
}),
)
expect(result).toContain(
"1 active memory (page 1 of 1, 1 memory entry total), newest first.",
)
expect(result).toContain("- [mem_1] User prefers dark mode")
expect(result).toContain(
"version 2 | updated 2026-06-10 | 1 previous version",
)
expect(result).toContain("Source documents: doc_1, doc_2")
})
it("excludes forgotten and superseded entries", () => {
const result = formatMemoryEntriesList(
makeMemoryResponse({
memoryEntries: [
makeMemory("Current fact"),
makeMemory("Forgotten fact", { isForgotten: true }),
makeMemory("Old fact", { isLatest: false }),
],
pagination: { currentPage: 1, limit: 10, totalItems: 3, totalPages: 1 },
}),
)
expect(result).toContain("Current fact")
expect(result).not.toContain("Forgotten fact")
expect(result).not.toContain("Old fact")
})
it("flattens and truncates oversized memory text", () => {
const result = formatMemoryEntriesList(
makeMemoryResponse({
memoryEntries: [
makeMemory("line one\nline two"),
makeMemory(`start ${"x".repeat(600)}`),
],
pagination: { currentPage: 1, limit: 10, totalItems: 2, totalPages: 1 },
}),
)
expect(result).toContain("line one line two")
expect(result).toContain("... [truncated]")
})
})
describe("formatDocument", () => {
const document: DocumentDetails = {
id: "doc_1",
connectionId: null,
content: "Original input",
createdAt: "2026-06-12T08:00:00Z",
customId: null,
metadata: null,
ogImage: null,
raw: "Full extracted document text",
source: "text",
spatialPoint: null,
status: "done",
summary: "A compact summary.",
title: "Preferences",
type: "text",
updatedAt: "2026-06-12T09:00:00Z",
url: null,
}
it("returns document metadata, summary, and full available content", () => {
const result = formatDocument(document)
expect(result).toContain("# Preferences")
expect(result).toContain("Document ID: doc_1")
expect(result).toContain("## Summary\nA compact summary.")
expect(result).toContain("## Content\nFull extracted document text")
expect(result).not.toContain("Original input")
})
it("falls back to the original content when raw content is absent", () => {
const result = formatDocument({ ...document, raw: null })
expect(result).toContain("## Content\nOriginal input")
})
})

View file

@ -0,0 +1,62 @@
import { afterEach, describe, expect, it, vi } from "vitest"
import { SupermemoryClient } from "."
describe("SupermemoryClient memory listing", () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it("calls the canonical memory-list endpoint with the selected workspace", async () => {
const responseBody = {
memoryEntries: [
{
id: "mem_1",
memory: "User prefers dark mode",
version: 1,
isLatest: true,
isForgotten: false,
createdAt: "2026-07-29T00:00:00.000Z",
updatedAt: "2026-07-29T00:00:00.000Z",
history: [],
documentIds: ["doc_1"],
},
],
pagination: {
currentPage: 2,
limit: 20,
totalItems: 21,
totalPages: 2,
},
}
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify(responseBody), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
)
vi.stubGlobal("fetch", fetchMock)
const client = new SupermemoryClient(
"oauth-token",
"snowcone_grande",
"https://api.example.com",
)
await expect(client.listMemoryEntries(2, 20)).resolves.toEqual(responseBody)
expect(fetchMock).toHaveBeenCalledOnce()
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
expect(url).toBe("https://api.example.com/v4/memories/list")
expect(init.method).toBe("POST")
expect(init.headers).toMatchObject({
Authorization: "Bearer oauth-token",
"Content-Type": "application/json",
})
expect(JSON.parse(init.body as string)).toEqual({
containerTags: ["snowcone_grande"],
page: 2,
limit: 20,
sort: "createdAt",
order: "desc",
})
})
})

View file

@ -1,4 +1,8 @@
import Supermemory from "supermemory"
import type {
DocumentGetResponse,
DocumentListResponse as SdkDocumentListResponse,
} from "supermemory/resources/documents"
import type {
ContainerTag,
DocumentMemoryEntry,
@ -17,6 +21,51 @@ export type {
DocumentsApiResponse,
}
export type DocumentSummary = SdkDocumentListResponse["memories"][number]
export type DocumentDetails = DocumentGetResponse
export interface DocumentsListResponse {
documents: DocumentSummary[]
pagination: SdkDocumentListResponse["pagination"]
}
export interface MemoryEntryHistory {
id: string
memory: string
version: number
createdAt: string
updatedAt: string
parentMemoryId?: string | null
rootMemoryId?: string | null
isLatest?: boolean
isForgotten?: boolean
}
export interface MemoryEntry {
id: string
memory: string
version: number
isLatest: boolean
isForgotten: boolean
isStatic?: boolean
isInference?: boolean
createdAt: string
updatedAt: string
sourceCount?: number
documentIds?: string[]
history?: MemoryEntryHistory[]
}
export interface MemoryEntriesResponse {
memoryEntries: MemoryEntry[]
pagination: {
currentPage: number
limit: number
totalItems: number
totalPages: number
}
}
export type Memory =
| {
id: string
@ -304,6 +353,69 @@ export class SupermemoryClient {
}
}
async listDocuments(page = 1, limit = 50): Promise<DocumentsListResponse> {
try {
const result = await this.client.documents.list({
containerTags: [this.containerTag],
page,
limit,
sort: "createdAt",
order: "desc",
includeContent: false,
})
return {
documents: result.memories ?? [],
pagination: result.pagination,
}
} catch (error) {
this.handleError(error)
}
}
async getDocument(id: string): Promise<DocumentDetails> {
try {
return await this.client.documents.get(id)
} catch (error) {
this.handleError(error)
}
}
async listMemoryEntries(
page = 1,
limit = 50,
): Promise<MemoryEntriesResponse> {
try {
const response = await fetch(`${this.apiUrl}/v4/memories/list`, {
method: "POST",
headers: {
Authorization: `Bearer ${this.bearerToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
containerTags: [this.containerTag],
page,
limit,
sort: "createdAt",
order: "desc",
}),
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
})
if (!response.ok) {
const message = await response.text()
throw Object.assign(
new Error(message || "Failed to fetch memory entries"),
{ status: response.status },
)
}
return (await response.json()) as MemoryEntriesResponse
} catch (error) {
this.handleError(error)
}
}
async uploadFile(
fileData: ArrayBuffer,
fileName: string,

View file

@ -4,5 +4,10 @@ export const containerTagSchema = z
.string()
.min(1, "Container tag is required")
.max(128, "Container tag exceeds maximum length")
.describe("Workspace key returned by listSpaces")
export const optionalContainerTagSchema = containerTagSchema.optional()
export const optionalContainerTagSchema = containerTagSchema
.optional()
.describe(
"Workspace key to use for this call. If the user names a workspace, call listSpaces to resolve its key and pass it here. Omit only when the user means the active workspace.",
)

View file

@ -1,51 +1,170 @@
import type { DocumentsApiResponse } from "./client"
import type {
DocumentDetails,
DocumentsListResponse,
MemoryEntriesResponse,
} from "./client"
// Listing must stay lightweight: memory entries are extracted facts, not raw
// document content, so responses fit client output limits at the max page size.
const MAX_LIST_MEMORY_CHARS = 500
const MAX_LIST_FIELD_CHARS = 500
const MAX_DOCUMENT_CONTENT_CHARS = 200_000
export function formatMemoriesList(response: DocumentsApiResponse): string {
function compactText(value: string, maxChars = MAX_LIST_FIELD_CHARS): string {
const text = value.replace(/\s+/g, " ").trim()
return text.length > maxChars
? `${text.slice(0, maxChars)} ... [truncated]`
: text
}
function day(value: string | null | undefined): string {
return value?.slice(0, 10) ?? ""
}
function paginationSummary(
currentPage: number,
totalPages: number,
totalItems: number,
singularItemName: string,
pluralItemName: string,
): string {
const itemName = totalItems === 1 ? singularItemName : pluralItemName
return `page ${currentPage} of ${totalPages}, ${totalItems} ${itemName} total`
}
export function formatDocumentsList(response: DocumentsListResponse): 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."
: "No documents 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`
const blocks = documents.map((document) => {
const title = document.title?.trim() || "(untitled)"
const lines = [
`- [${document.id}] "${title}" (${document.type}, ${document.status}, ${day(document.createdAt)})`,
]
if (document.summary?.trim()) {
lines.push(` Summary: ${compactText(document.summary)}`)
}
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")
return 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 = [
`${documents.length} document${documents.length === 1 ? "" : "s"} (${paginationSummary(
pagination.currentPage,
pagination.totalPages,
pagination.totalItems,
"document",
"documents",
)}), newest first.`,
"",
blocks.join("\n\n"),
"",
"Use getDocument with a document ID to read its content.",
]
if (pagination.currentPage < pagination.totalPages) {
parts.push(
`More available - call listDocuments with page: ${pagination.currentPage + 1}.`,
)
}
return parts.join("\n")
}
export function formatMemoryEntriesList(
response: MemoryEntriesResponse,
): string {
const { memoryEntries, pagination } = response
const activeEntries = memoryEntries.filter(
(entry) => entry.isForgotten !== true && entry.isLatest !== false,
)
if (activeEntries.length === 0) {
return pagination.currentPage > 1
? `No active memories on page ${pagination.currentPage} (${pagination.totalPages} page${pagination.totalPages === 1 ? "" : "s"} total).`
: "No active memories stored yet."
}
const blocks = activeEntries.map((entry) => {
const lines = [`- [${entry.id}] ${compactText(entry.memory)}`]
const details = [
`version ${entry.version}`,
`updated ${day(entry.updatedAt)}`,
]
if (entry.history && entry.history.length > 0) {
details.push(
`${entry.history.length} previous ${
entry.history.length === 1 ? "version" : "versions"
}`,
)
}
lines.push(` ${details.join(" | ")}`)
if (entry.documentIds && entry.documentIds.length > 0) {
lines.push(` Source documents: ${entry.documentIds.join(", ")}`)
}
return lines.join("\n")
})
const parts = [
`${activeEntries.length} active memor${activeEntries.length === 1 ? "y" : "ies"} (${paginationSummary(
pagination.currentPage,
pagination.totalPages,
pagination.totalItems,
"memory entry",
"memory entries",
)}), newest first.`,
"",
blocks.join("\n\n"),
]
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")
}
function documentContent(document: DocumentDetails): string | null {
if (typeof document.raw === "string" && document.raw.trim()) {
return document.raw
}
if (document.raw !== null && document.raw !== undefined) {
return JSON.stringify(document.raw, null, 2)
}
if (document.content?.trim()) return document.content
return null
}
export function formatDocument(document: DocumentDetails): string {
const title = document.title?.trim() || "(untitled)"
const parts = [
`# ${title}`,
`Document ID: ${document.id}`,
`Type: ${document.type}`,
`Status: ${document.status}`,
`Created: ${document.createdAt}`,
`Updated: ${document.updatedAt}`,
]
if (document.url) parts.push(`URL: ${document.url}`)
if (document.summary?.trim()) {
parts.push("", "## Summary", compactText(document.summary, 4_000))
}
const content = documentContent(document)
if (content) {
const truncated =
content.length > MAX_DOCUMENT_CONTENT_CHARS
? `${content.slice(0, MAX_DOCUMENT_CONTENT_CHARS)}\n\n[Document content truncated]`
: content
parts.push("", "## Content", truncated)
} else {
parts.push("", "No document content is available.")
}
return parts.join("\n")
}

View file

@ -0,0 +1,37 @@
import { z } from "zod"
import { formatDocument } from "../format"
import { READ_ONLY_TOOL_ANNOTATIONS } from "./annotations"
import type { ToolDeps } from "./types"
export function register(deps: ToolDeps) {
const inputSchema = z.object({
documentId: z
.string()
.min(1, "Document ID is required")
.max(255, "Document ID exceeds maximum length")
.describe("Document ID returned by listDocuments or a memory result"),
})
deps.server.registerTool(
"getDocument",
{
title: "Get Document",
description:
"Read one stored document by ID, including its summary and available content. Use listDocuments in the intended workspace to discover document IDs.",
inputSchema,
annotations: READ_ONLY_TOOL_ANNOTATIONS,
},
async (args) => {
try {
const client = deps.getClient()
const document = await client.getDocument(args.documentId)
return {
content: [{ type: "text" as const, text: formatDocument(document) }],
}
} catch (error) {
return deps.errorResult(error)
}
},
)
}

View file

@ -1,7 +1,9 @@
import * as addMemory from "./add-memory"
import * as fetchGraphData from "./fetch-graph-data"
import * as getDocument from "./get-document"
import * as guidedSave from "./guided-save"
import * as listContainerTags from "./list-container-tags"
import * as listDocuments from "./list-documents"
import * as listMemories from "./list-memories"
import * as memoryGraph from "./memory-graph"
import * as saveMemory from "./save-memory"
@ -15,6 +17,8 @@ import * as whoAmI from "./who-am-i"
export function registerAllTools(deps: ToolDeps) {
searchMemory.register(deps)
listDocuments.register(deps)
getDocument.register(deps)
listMemories.register(deps)
listContainerTags.register(deps)
whoAmI.register(deps)

View file

@ -7,7 +7,7 @@ export function register(deps: ToolDeps) {
"listSpaces",
{
description:
"List the spaces available to you. Spaces are the workspaces you organize memories into — returns each space's name, identifier, emoji, document/memory counts, and last activity. The list is auto-filtered to spaces you have access to.",
"List the workspaces available to the user. Returns each workspace's name, key, emoji, document/memory counts, and last activity. Use this first to resolve a named workspace before calling a workspace-aware tool, or when the user asks which workspace may contain something. The list is auto-filtered to workspaces the user can access.",
inputSchema: z.object({}),
annotations: READ_ONLY_TOOL_ANNOTATIONS,
},

View file

@ -0,0 +1,53 @@
import { z } from "zod"
import { optionalContainerTagSchema } from "../container-tag"
import { formatDocumentsList } from "../format"
import { READ_ONLY_TOOL_ANNOTATIONS } from "./annotations"
import type { ToolDeps } from "./types"
export function register(deps: ToolDeps) {
const inputSchema = 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 (default 10, max 50)"),
containerTag: optionalContainerTagSchema,
})
deps.server.registerTool(
"listDocuments",
{
title: "List Documents",
description:
"List documents in one workspace with their IDs, titles, types, processing status, dates, and summaries. This does not return full document content; use getDocument with an ID from this result to read one document. When the user names a workspace, resolve it with listSpaces and pass containerTag; otherwise use the active workspace.",
inputSchema,
annotations: READ_ONLY_TOOL_ANNOTATIONS,
},
async (args) => {
try {
const effectiveTag = await deps.resolveContainerTag(args.containerTag)
const client = deps.getClient(effectiveTag)
const data = await client.listDocuments(
args.page ?? 1,
args.limit ?? 10,
)
return {
content: [{ type: "text" as const, text: formatDocumentsList(data) }],
}
} catch (error) {
return deps.errorResult(error)
}
},
)
}

View file

@ -1,6 +1,6 @@
import { z } from "zod"
import { optionalContainerTagSchema } from "../container-tag"
import { formatMemoriesList } from "../format"
import { formatMemoryEntriesList } from "../format"
import { READ_ONLY_TOOL_ANNOTATIONS } from "./annotations"
import type { ToolDeps } from "./types"
@ -20,17 +20,16 @@ export function register(deps: ToolDeps) {
.max(50)
.optional()
.default(10)
.describe(
"Documents per page; each document groups its extracted memories (default 10, max 50)",
),
.describe("Memory entries per page (default 10, max 50)"),
containerTag: optionalContainerTagSchema,
})
deps.server.registerTool(
"listMemories",
{
title: "List Memories",
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. For finding memories relevant to a topic, use search_memory instead.",
"List the latest extracted memory entries in one workspace, including stable memory IDs, version information, and source document IDs. This lists memories directly, not documents. When the user names a workspace, resolve it with listSpaces and pass containerTag; otherwise use the active workspace. Use search_memory instead for semantic recall.",
inputSchema,
annotations: READ_ONLY_TOOL_ANNOTATIONS,
},
@ -38,15 +37,15 @@ export function register(deps: ToolDeps) {
try {
const effectiveTag = await deps.resolveContainerTag(args.containerTag)
const client = deps.getClient(effectiveTag)
const containerTags = effectiveTag ? [effectiveTag] : undefined
const data = await client.getDocuments(
containerTags,
const data = await client.listMemoryEntries(
args.page ?? 1,
args.limit ?? 10,
)
return {
content: [{ type: "text" as const, text: formatMemoriesList(data) }],
content: [
{ type: "text" as const, text: formatMemoryEntriesList(data) },
],
}
} catch (error) {
return deps.errorResult(error)

View file

@ -15,7 +15,7 @@ export function register(deps: ToolDeps) {
{
title: "Memory Graph",
description:
"Visualize the user's memory graph as an interactive force-directed graph.",
"Render the workspace's memory graph directly as an interactive MCP App. This tool is the final visualization; do not create another graph, file, or artifact unless the user explicitly asks for one. When the user names a workspace, resolve it with listSpaces and pass containerTag.",
inputSchema,
annotations: READ_ONLY_TOOL_ANNOTATIONS,
_meta: appToolMeta(),
@ -44,7 +44,7 @@ export function register(deps: ToolDeps) {
content: [
{
type: "text" as const,
text: `Memory Graph: ${result.documents.length} documents, ${memoryCount} memories${effectiveTag ? `. Workspace: ${effectiveTag}` : ""}`,
text: `Rendered the interactive Memory Graph MCP App: ${result.documents.length} documents, ${memoryCount} memories${effectiveTag ? `. Workspace: ${effectiveTag}` : ""}. Do not create a duplicate graph or artifact unless the user explicitly requests one.`,
},
],
structuredContent: sc,

View file

@ -18,7 +18,7 @@ export function register(deps: ToolDeps) {
"search_memory",
{
description:
"Search the user's memories with a natural-language query. Returns relevant memories plus their profile summary.",
"Search memories in one workspace with a natural-language query. Returns relevant memories plus that workspace's profile summary. When the user names a workspace, resolve it with listSpaces and pass containerTag; otherwise use the active workspace.",
inputSchema,
annotations: READ_ONLY_TOOL_ANNOTATIONS,
},

View file

@ -16,6 +16,8 @@ export function register(deps: ToolDeps) {
deps.getSession(),
deps.getActiveContainerTag(),
])
const client = deps.getClientInfo(context)
const sessionId = context.sessionId
return {
content: [
{
@ -32,8 +34,8 @@ export function register(deps: ToolDeps) {
? session.containerTags
: null,
scope: session.scope,
client: deps.getClientInfo(context),
sessionId: context.sessionId ?? null,
...(client ? { client } : {}),
...(sessionId ? { sessionId } : {}),
}),
},
],

View file

@ -1,4 +1,5 @@
import { describe, expect, it, vi } from "vitest"
import { optionalContainerTagSchema } from "./container-tag"
import { resolveContainerTag, workspaceStateName } from "./workspace"
describe("workspace application state", () => {
@ -33,4 +34,12 @@ describe("workspace application state", () => {
resolveContainerTag(undefined, vi.fn().mockResolvedValue(undefined)),
).resolves.toBeUndefined()
})
it("tells the model how to route explicit workspace requests", () => {
expect(optionalContainerTagSchema.description).toContain(
"If the user names a workspace",
)
expect(optionalContainerTagSchema.description).toContain("listSpaces")
expect(optionalContainerTagSchema.description).toContain("active workspace")
})
})