From b7a7b19490dfd76e56ac3f9524e0add49378dda2 Mon Sep 17 00:00:00 2001 From: addyCooks Date: Tue, 8 Sep 2026 13:02:01 +0530 Subject: [PATCH] fix(mcp): let add_memory pin a title through metadata The ingest API has no title field, so add_memory now accepts an optional title and pins it through metadata.title, trimmed and omitted when blank. list_documents / get_document already read metadata.title back, so this gives MCP callers title control without an API change. Fixes #1425 --- apps/mcp/src/server/client/index.test.ts | 28 +++++++++ apps/mcp/src/server/client/index.ts | 7 ++- apps/mcp/src/server/format.test.ts | 76 ++++++++++++++++++++++++ apps/mcp/src/server/format.ts | 16 ++++- apps/mcp/src/server/tools/add-memory.ts | 13 +++- 5 files changed, 136 insertions(+), 4 deletions(-) create mode 100644 apps/mcp/src/server/format.test.ts diff --git a/apps/mcp/src/server/client/index.test.ts b/apps/mcp/src/server/client/index.test.ts index 464c2080..da54ecb2 100644 --- a/apps/mcp/src/server/client/index.test.ts +++ b/apps/mcp/src/server/client/index.test.ts @@ -95,6 +95,34 @@ describe("SupermemoryClient", () => { }) }) + it("pins an explicit title through metadata", async () => { + sdk.add.mockResolvedValue({ id: "doc_2" }) + + await client("work").createMemory("remember this", { + title: " Quarterly planning notes ", + }) + + expect(sdk.add).toHaveBeenCalledWith({ + content: "remember this", + containerTag: "work", + metadata: { + sm_source: "supermemory-mcp", + title: "Quarterly planning notes", + }, + }) + }) + + it("leaves the title out when it is blank or absent", async () => { + sdk.add.mockResolvedValue({ id: "doc_3" }) + + await client("work").createMemory("a", { title: " " }) + await client("work").createMemory("b", {}) + + for (const call of sdk.add.mock.calls) { + expect(call[0].metadata).toEqual({ sm_source: "supermemory-mcp" }) + } + }) + it("treats an empty space string as an unscoped connection", async () => { sdk.search.memories.mockResolvedValue({ results: [], diff --git a/apps/mcp/src/server/client/index.ts b/apps/mcp/src/server/client/index.ts index 706e2734..874bb21b 100644 --- a/apps/mcp/src/server/client/index.ts +++ b/apps/mcp/src/server/client/index.ts @@ -159,12 +159,17 @@ export class SupermemoryClient { async createMemory( content: string, + options?: { title?: string }, ): Promise<{ id: string; status: string; containerTag: string }> { try { + const title = options?.title?.trim() const result = await this.client.add({ content, containerTag: this.containerTag, - metadata: { sm_source: MCP_SOURCE }, + metadata: { + sm_source: MCP_SOURCE, + ...(title ? { title } : {}), + }, }) return { id: result.id, diff --git a/apps/mcp/src/server/format.test.ts b/apps/mcp/src/server/format.test.ts new file mode 100644 index 00000000..4d923c5e --- /dev/null +++ b/apps/mcp/src/server/format.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest" +import type { DocumentDetails, DocumentsListResponse } from "./client" +import { formatDocument, formatDocumentsList } from "./format" + +function list(document: Record): DocumentsListResponse { + return { + documents: [ + { + id: "doc_1", + type: "text", + status: "done", + createdAt: "2026-08-07T12:53:00.000Z", + ...document, + }, + ], + pagination: { currentPage: 1, totalPages: 1, totalItems: 1, limit: 50 }, + } as unknown as DocumentsListResponse +} + +function details(document: Record): DocumentDetails { + return { + id: "doc_1", + type: "text", + status: "done", + createdAt: "2026-08-07T12:53:00.000Z", + updatedAt: "2026-08-07T12:53:00.000Z", + content: "body", + ...document, + } as unknown as DocumentDetails +} + +describe("document titles in MCP output", () => { + it("prefers a pinned metadata title over the stored one", () => { + expect( + formatDocumentsList( + list({ title: "Paraphrase", metadata: { title: "Pinned" } }), + ), + ).toContain('"Pinned"') + expect( + formatDocument( + details({ title: "Paraphrase", metadata: { title: "Pinned" } }), + ), + ).toContain("# Pinned") + }) + + it("uses the pinned title when titling produced nothing", () => { + expect( + formatDocumentsList(list({ title: null, metadata: { title: "Pinned" } })), + ).toContain('"Pinned"') + }) + + it("falls back to the stored title, then to a placeholder", () => { + expect(formatDocumentsList(list({ title: "Stored" }))).toContain('"Stored"') + expect(formatDocumentsList(list({ title: null }))).toContain("(untitled)") + expect(formatDocument(details({ title: null }))).toContain("# (untitled)") + }) + + it("ignores metadata that is blank or not a string", () => { + expect( + formatDocumentsList( + list({ title: "Stored", metadata: { title: " " } }), + ), + ).toContain('"Stored"') + expect( + formatDocumentsList(list({ title: "Stored", metadata: { title: 42 } })), + ).toContain('"Stored"') + }) + + it("survives non-object metadata", () => { + for (const metadata of [null, "raw", 7, true, ["a"]]) { + expect( + formatDocumentsList(list({ title: "Stored", metadata })), + ).toContain('"Stored"') + } + }) +}) diff --git a/apps/mcp/src/server/format.ts b/apps/mcp/src/server/format.ts index c9bb3f11..b35de6f5 100644 --- a/apps/mcp/src/server/format.ts +++ b/apps/mcp/src/server/format.ts @@ -18,6 +18,18 @@ function day(value: string | null | undefined): string { return value?.slice(0, 10) ?? "" } +function documentTitle(document: { + title?: string | null + metadata?: unknown +}): string { + const metadata = document.metadata + if (metadata && typeof metadata === "object") { + const pinned = (metadata as Record).title + if (typeof pinned === "string" && pinned.trim()) return pinned.trim() + } + return document.title?.trim() || "(untitled)" +} + function paginationSummary( currentPage: number, totalPages: number, @@ -39,7 +51,7 @@ export function formatDocumentsList(response: DocumentsListResponse): string { } const blocks = documents.map((document) => { - const title = document.title?.trim() || "(untitled)" + const title = documentTitle(document) const lines = [ `- [${document.id}] "${title}" (${document.type}, ${document.status}, ${day(document.createdAt)})`, ] @@ -150,7 +162,7 @@ export function getDocumentContent(document: DocumentDetails): { } export function formatDocument(document: DocumentDetails): string { - const title = document.title?.trim() || "(untitled)" + const title = documentTitle(document) const parts = [ `# ${title}`, `Document ID: ${document.id}`, diff --git a/apps/mcp/src/server/tools/add-memory.ts b/apps/mcp/src/server/tools/add-memory.ts index c708deb5..62a08390 100644 --- a/apps/mcp/src/server/tools/add-memory.ts +++ b/apps/mcp/src/server/tools/add-memory.ts @@ -11,6 +11,15 @@ export function register(deps: ToolDeps) { .max(200000, "Content exceeds maximum length") .describe("The memory content to save or forget"), action: z.enum(["save", "forget"]).optional().default("save"), + title: z + .string() + .trim() + .min(1) + .max(200) + .optional() + .describe( + "Optional title for the saved memory. Overrides the title generated during processing. Ignored when action is 'forget'.", + ), containerTag: optionalContainerTagSchema, }) @@ -42,7 +51,9 @@ export function register(deps: ToolDeps) { } } - const result = await client.createMemory(args.content) + const result = await client.createMemory(args.content, { + title: args.title, + }) const message = `Memory saved (ID: ${result.id}, space: ${result.containerTag})` const structuredContent: AddMemoryOutput = { action: "save",