diff --git a/apps/mcp/README.md b/apps/mcp/README.md index 61309632..dcf3155b 100644 --- a/apps/mcp/README.md +++ b/apps/mcp/README.md @@ -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 diff --git a/apps/mcp/e2e/discovery.test.ts b/apps/mcp/e2e/discovery.test.ts index 9d1e2112..beed605c 100644 --- a/apps/mcp/e2e/discovery.test.ts +++ b/apps/mcp/e2e/discovery.test.ts @@ -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 () => { diff --git a/apps/mcp/e2e/graph.test.ts b/apps/mcp/e2e/graph.test.ts index 0d0bbe71..2ca16128 100644 --- a/apps/mcp/e2e/graph.test.ts +++ b/apps/mcp/e2e/graph.test.ts @@ -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 diff --git a/apps/mcp/e2e/list-memories.test.ts b/apps/mcp/e2e/list-memories.test.ts index daac14ec..4bf49e06 100644 --- a/apps/mcp/e2e/list-memories.test.ts +++ b/apps/mcp/e2e/list-memories.test.ts @@ -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) + }, +) diff --git a/apps/mcp/package.json b/apps/mcp/package.json index 8d731aa4..498e14ad 100644 --- a/apps/mcp/package.json +++ b/apps/mcp/package.json @@ -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" }, diff --git a/apps/mcp/src/format.test.ts b/apps/mcp/src/format.test.ts index 8f3bfca6..097946b2 100644 --- a/apps/mcp/src/format.test.ts +++ b/apps/mcp/src/format.test.ts @@ -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 { +function makeDocumentsResponse( + overrides: Partial = {}, +): DocumentsListResponse { return { documents: [], pagination: { currentPage: 1, limit: 10, totalItems: 0, totalPages: 1 }, @@ -12,50 +20,51 @@ function makeResponse( } } -function makeEntry(memory: string, extra: Record = {}) { +function makeMemoryResponse( + overrides: Partial = {}, +): MemoryEntriesResponse { + return { + memoryEntries: [], + pagination: { currentPage: 1, limit: 10, totalItems: 0, totalPages: 1 }, + ...overrides, + } +} + +function makeMemory(memory: string, extra: Record = {}) { 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") }) }) diff --git a/apps/mcp/src/server/client/index.test.ts b/apps/mcp/src/server/client/index.test.ts new file mode 100644 index 00000000..c7088e1c --- /dev/null +++ b/apps/mcp/src/server/client/index.test.ts @@ -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", + }) + }) +}) diff --git a/apps/mcp/src/server/client/index.ts b/apps/mcp/src/server/client/index.ts index 87c22077..9a3072b7 100644 --- a/apps/mcp/src/server/client/index.ts +++ b/apps/mcp/src/server/client/index.ts @@ -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 { + 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 { + try { + return await this.client.documents.get(id) + } catch (error) { + this.handleError(error) + } + } + + async listMemoryEntries( + page = 1, + limit = 50, + ): Promise { + 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, diff --git a/apps/mcp/src/server/container-tag.ts b/apps/mcp/src/server/container-tag.ts index 82098f40..bad06e7d 100644 --- a/apps/mcp/src/server/container-tag.ts +++ b/apps/mcp/src/server/container-tag.ts @@ -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.", + ) diff --git a/apps/mcp/src/server/format.ts b/apps/mcp/src/server/format.ts index 3c116279..738d9275 100644 --- a/apps/mcp/src/server/format.ts +++ b/apps/mcp/src/server/format.ts @@ -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") } diff --git a/apps/mcp/src/server/tools/get-document.ts b/apps/mcp/src/server/tools/get-document.ts new file mode 100644 index 00000000..59cfbbf0 --- /dev/null +++ b/apps/mcp/src/server/tools/get-document.ts @@ -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) + } + }, + ) +} diff --git a/apps/mcp/src/server/tools/index.ts b/apps/mcp/src/server/tools/index.ts index 229fbc7e..ca9684df 100644 --- a/apps/mcp/src/server/tools/index.ts +++ b/apps/mcp/src/server/tools/index.ts @@ -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) diff --git a/apps/mcp/src/server/tools/list-container-tags.ts b/apps/mcp/src/server/tools/list-container-tags.ts index cbdf8ffb..b3cf832d 100644 --- a/apps/mcp/src/server/tools/list-container-tags.ts +++ b/apps/mcp/src/server/tools/list-container-tags.ts @@ -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, }, diff --git a/apps/mcp/src/server/tools/list-documents.ts b/apps/mcp/src/server/tools/list-documents.ts new file mode 100644 index 00000000..1a722766 --- /dev/null +++ b/apps/mcp/src/server/tools/list-documents.ts @@ -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) + } + }, + ) +} diff --git a/apps/mcp/src/server/tools/list-memories.ts b/apps/mcp/src/server/tools/list-memories.ts index ef4eb46f..6f70772d 100644 --- a/apps/mcp/src/server/tools/list-memories.ts +++ b/apps/mcp/src/server/tools/list-memories.ts @@ -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) diff --git a/apps/mcp/src/server/tools/memory-graph.ts b/apps/mcp/src/server/tools/memory-graph.ts index 3768ea63..b826a803 100644 --- a/apps/mcp/src/server/tools/memory-graph.ts +++ b/apps/mcp/src/server/tools/memory-graph.ts @@ -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, diff --git a/apps/mcp/src/server/tools/search-memory.ts b/apps/mcp/src/server/tools/search-memory.ts index 59a1d06f..ca0fc1cd 100644 --- a/apps/mcp/src/server/tools/search-memory.ts +++ b/apps/mcp/src/server/tools/search-memory.ts @@ -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, }, diff --git a/apps/mcp/src/server/tools/who-am-i.ts b/apps/mcp/src/server/tools/who-am-i.ts index 7fa2a42d..d4b05f2d 100644 --- a/apps/mcp/src/server/tools/who-am-i.ts +++ b/apps/mcp/src/server/tools/who-am-i.ts @@ -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 } : {}), }), }, ], diff --git a/apps/mcp/src/server/workspace.test.ts b/apps/mcp/src/server/workspace.test.ts index 9d3c92a4..a9a466d4 100644 --- a/apps/mcp/src/server/workspace.test.ts +++ b/apps/mcp/src/server/workspace.test.ts @@ -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") + }) })