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
This commit is contained in:
addyCooks 2026-09-08 13:02:01 +05:30
parent b67bec88e5
commit b7a7b19490
5 changed files with 136 additions and 4 deletions

View file

@ -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: [],

View file

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

View file

@ -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<string, unknown>): 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<string, unknown>): 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"')
}
})
})

View file

@ -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<string, unknown>).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}`,

View file

@ -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",