mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-10-01 02:01:40 +00:00
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:
parent
b67bec88e5
commit
b7a7b19490
5 changed files with 136 additions and 4 deletions
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
76
apps/mcp/src/server/format.test.ts
Normal file
76
apps/mcp/src/server/format.test.ts
Normal 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"')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue