From 017a80b37d1e07ce71a062ad40129dcf22289fa3 Mon Sep 17 00:00:00 2001 From: addyCooks Date: Wed, 19 Aug 2026 20:53:16 +0530 Subject: [PATCH 1/3] test(mcp): restore SupermemoryClient coverage and fix two blank error messages #1406 shipped the API-error unwrapping and the status-aware handleError fallbacks with a test file that had already been deleted in #1397, so SupermemoryClient - every outbound API call, extractApiErrorMessage, and all of handleError - had no coverage at all. Restores src/server/client/index.test.ts with 53 cases covering the SDK wiring, space scoping, result normalisation, forgetMemory's exact-match and similarity fallbacks, the raw-fetch endpoints, and the full status table. Two of those cases failed against the untested code: - A 403 whose body is {"error": ""} leaked the raw JSON envelope to the user, because extractApiErrorMessage falls through to the raw string when the recognised key holds an empty value. An envelope we parsed but that carries no message now yields undefined so the caller reaches its scope-aware fallback. - A status outside the mapped switch (409, 413, ...) with an empty body reached the user as an Error with an empty message. Unmapped statuses with no message now report the status instead. --- apps/mcp/src/server/client/index.test.ts | 666 +++++++++++++++++++++++ apps/mcp/src/server/client/index.ts | 2 + 2 files changed, 668 insertions(+) create mode 100644 apps/mcp/src/server/client/index.test.ts 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 000000000..464c20807 --- /dev/null +++ b/apps/mcp/src/server/client/index.test.ts @@ -0,0 +1,666 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { DEFAULT_PROJECT_ID, getMemoryText, SupermemoryClient } from "./index" + +const { sdk, sdkOptions } = vi.hoisted(() => ({ + sdk: { + add: vi.fn(), + profile: vi.fn(), + memories: { forget: vi.fn() }, + search: { memories: vi.fn() }, + documents: { list: vi.fn(), get: vi.fn() }, + }, + sdkOptions: vi.fn(), +})) + +vi.mock("supermemory", () => ({ + default: class { + constructor(options: unknown) { + sdkOptions(options) + Object.assign(this, sdk) + } + }, +})) + +const API_URL = "https://api.example.com" +const TOKEN = "sm_test_token" +const FORBIDDEN_FALLBACK = "read-only or scoped to specific spaces" + +const pagination = { + currentPage: 1, + limit: 50, + totalItems: 1, + totalPages: 1, +} + +const documentsPayload = { + documents: [ + { + id: "doc_1", + title: "Notes", + summary: null, + type: "text", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-02T00:00:00.000Z", + memoryEntries: [], + }, + ], + pagination, +} + +function client(containerTag?: string) { + return new SupermemoryClient(TOKEN, containerTag, API_URL) +} + +function apiError(message: string, status: number) { + return Object.assign(new Error(message), { status }) +} + +describe("SupermemoryClient", () => { + let fetchMock = vi.fn() + + beforeEach(() => { + fetchMock = vi.fn() + vi.stubGlobal("fetch", fetchMock) + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.clearAllMocks() + }) + + describe("configuration", () => { + it("identifies itself to the API with a source header and a timeout", () => { + client("work") + + expect(sdkOptions).toHaveBeenCalledWith({ + apiKey: TOKEN, + baseURL: API_URL, + timeout: 30_000, + defaultHeaders: { "x-sm-source": "supermemory-mcp" }, + }) + }) + + it("writes to the default project when no space is configured", async () => { + sdk.add.mockResolvedValue({ id: "doc_1" }) + + await expect(client().createMemory("remember this")).resolves.toEqual({ + id: "doc_1", + status: "queued", + containerTag: DEFAULT_PROJECT_ID, + }) + expect(sdk.add).toHaveBeenCalledWith({ + content: "remember this", + containerTag: DEFAULT_PROJECT_ID, + metadata: { sm_source: "supermemory-mcp" }, + }) + }) + + it("treats an empty space string as an unscoped connection", async () => { + sdk.search.memories.mockResolvedValue({ + results: [], + total: 0, + timing: 1, + }) + + await client("").search("query") + + expect(sdk.search.memories.mock.calls[0][0]).not.toHaveProperty( + "containerTag", + ) + }) + }) + + describe("search", () => { + it("omits the space filter when the connection is unscoped", async () => { + sdk.search.memories.mockResolvedValue({ + results: [], + total: 0, + timing: 1, + }) + + await client().search("query") + + expect(sdk.search.memories.mock.calls[0][0]).not.toHaveProperty( + "containerTag", + ) + }) + + it("sends the configured space and honours an explicit override", async () => { + sdk.search.memories.mockResolvedValue({ + results: [], + total: 0, + timing: 1, + }) + + await client("work").search("query") + expect(sdk.search.memories).toHaveBeenLastCalledWith({ + q: "query", + limit: 10, + containerTag: "work", + searchMode: "hybrid", + threshold: undefined, + }) + + await client("work").search("query", 3, 0.5, "personal") + expect(sdk.search.memories).toHaveBeenLastCalledWith({ + q: "query", + limit: 3, + containerTag: "personal", + searchMode: "hybrid", + threshold: 0.5, + }) + + await client().search("query", 10, undefined, "personal") + expect(sdk.search.memories.mock.calls[2][0]).toMatchObject({ + containerTag: "personal", + }) + }) + + it("normalises every result shape the API can return", async () => { + sdk.search.memories.mockResolvedValue({ + results: [ + { id: "a", memory: "remembered", similarity: 0.9, title: "Title" }, + { id: "b", chunk: "chunked", similarity: 0.8 }, + { id: "c", context: "context only", similarity: 0.7 }, + { id: "d", similarity: 0.6 }, + { id: "e", content: "full body", chunk: "excerpt", similarity: 0.5 }, + ], + total: 5, + timing: 12, + }) + + await expect(client("work").search("query")).resolves.toEqual({ + results: [ + { id: "a", memory: "remembered", similarity: 0.9, title: "Title" }, + { id: "b", chunk: "chunked", similarity: 0.8 }, + { id: "c", memory: "context only", similarity: 0.7 }, + { id: "d", memory: "", similarity: 0.6 }, + { + id: "e", + chunk: "full body", + content: "full body", + similarity: 0.5, + }, + ], + total: 5, + timing: 12, + }) + }) + + it("truncates oversized memory text", async () => { + sdk.search.memories.mockResolvedValue({ + results: [{ id: "a", memory: "x".repeat(200_001), similarity: 1 }], + total: 1, + timing: 1, + }) + + const { results } = await client("work").search("query") + const text = getMemoryText(results[0]) + + expect(text).toHaveLength(200_003) + expect(text.endsWith("...")).toBe(true) + }) + + it("rejects results that do not match the API contract", async () => { + sdk.search.memories.mockResolvedValue({ + results: [{ id: "a", memory: "no similarity" }], + total: 1, + timing: 1, + }) + + await expect(client("work").search("query")).rejects.toThrow( + "Search request failed", + ) + }) + }) + + describe("profile", () => { + it("returns an empty profile without calling the API when unscoped", async () => { + await expect(client().getProfile("who am i")).resolves.toEqual({ + profile: { static: [], dynamic: [] }, + }) + expect(sdk.profile).not.toHaveBeenCalled() + }) + + it("maps profile search results when the API returns them", async () => { + sdk.profile.mockResolvedValue({ + profile: { static: ["vegetarian"], dynamic: ["lives in Berlin"] }, + searchResults: { + results: [{ id: "a", memory: "likes pizza", similarity: 0.4 }], + total: 1, + timing: 3, + }, + }) + + await expect(client("work").getProfile("who am i")).resolves.toEqual({ + profile: { static: ["vegetarian"], dynamic: ["lives in Berlin"] }, + searchResults: { + results: [{ id: "a", memory: "likes pizza", similarity: 0.4 }], + total: 1, + timing: 3, + }, + }) + expect(sdk.profile).toHaveBeenCalledWith({ + containerTag: "work", + q: "who am i", + }) + }) + + it("defaults missing profile sections to empty lists", async () => { + sdk.profile.mockResolvedValue({ profile: { static: null } }) + + await expect(client("work").getProfile()).resolves.toEqual({ + profile: { static: [], dynamic: [] }, + }) + }) + + it("unwraps the API message from a forbidden profile request", async () => { + sdk.profile.mockRejectedValue( + apiError(JSON.stringify({ error: "Profile is disabled" }), 403), + ) + + await expect(client("work").getProfile()).rejects.toThrow( + "Profile request failed: Profile is disabled", + ) + }) + }) + + describe("forgetMemory", () => { + it("forgets an exact match without searching", async () => { + sdk.memories.forget.mockResolvedValue({ id: "mem_1" }) + + await expect(client("work").forgetMemory("likes pizza")).resolves.toEqual( + { + success: true, + message: "Successfully forgot memory (exact match) with ID: mem_1", + containerTag: "work", + }, + ) + expect(sdk.search.memories).not.toHaveBeenCalled() + }) + + it("falls back to a similarity search when there is no exact match", async () => { + sdk.memories.forget + .mockRejectedValueOnce(apiError("not found", 404)) + .mockResolvedValueOnce({ id: "mem_2" }) + sdk.search.memories.mockResolvedValue({ + results: [{ id: "mem_2", memory: "likes pizza", similarity: 0.912 }], + total: 1, + timing: 2, + }) + + await expect(client("work").forgetMemory("pizza")).resolves.toEqual({ + success: true, + message: 'Forgot similar memory (similarity: 0.91): "likes pizza"', + containerTag: "work", + }) + expect(sdk.search.memories).toHaveBeenCalledWith({ + q: "pizza", + limit: 5, + containerTag: "work", + searchMode: "hybrid", + threshold: 0.85, + }) + expect(sdk.memories.forget).toHaveBeenLastCalledWith({ + id: "mem_2", + containerTag: "work", + }) + }) + + it("truncates the memory text quoted back in the confirmation", async () => { + sdk.memories.forget + .mockRejectedValueOnce(apiError("not found", 404)) + .mockResolvedValueOnce({ id: "mem_2" }) + sdk.search.memories.mockResolvedValue({ + results: [{ id: "mem_2", memory: "y".repeat(150), similarity: 0.9 }], + total: 1, + timing: 2, + }) + + const result = await client("work").forgetMemory("pizza") + + expect(result.message).toBe( + `Forgot similar memory (similarity: 0.90): "${"y".repeat(100)}..."`, + ) + }) + + it("reports when nothing matches", async () => { + sdk.memories.forget.mockRejectedValue(apiError("not found", 404)) + sdk.search.memories.mockResolvedValue({ + results: [], + total: 0, + timing: 1, + }) + + await expect(client("work").forgetMemory("pizza")).resolves.toEqual({ + success: false, + message: "No matching memory found to forget.", + containerTag: "work", + }) + }) + + it("refuses to delete when only chunks matched", async () => { + sdk.memories.forget.mockRejectedValue(apiError("not found", 404)) + sdk.search.memories.mockResolvedValue({ + results: [{ id: "chunk_1", chunk: "pizza night", similarity: 0.9 }], + total: 1, + timing: 1, + }) + + await expect(client("work").forgetMemory("pizza")).resolves.toEqual({ + success: false, + message: "No matching memory found (only chunks matched).", + containerTag: "work", + }) + expect(sdk.memories.forget).toHaveBeenCalledOnce() + }) + + it("does not fall back when the failure is not a missing memory", async () => { + sdk.memories.forget.mockRejectedValue(apiError("Key is read-only", 403)) + + await expect(client("work").forgetMemory("pizza")).rejects.toThrow( + "Forget memory request failed: Key is read-only", + ) + expect(sdk.search.memories).not.toHaveBeenCalled() + }) + }) + + describe("documents", () => { + it("posts the space filter and pagination", async () => { + fetchMock.mockResolvedValue( + new Response(JSON.stringify(documentsPayload)), + ) + + await expect(client("work").getDocuments(["work"])).resolves.toEqual( + documentsPayload, + ) + + const [url, init] = fetchMock.mock.calls[0] + expect(url).toBe(`${API_URL}/v3/documents/documents`) + expect(init.method).toBe("POST") + expect(init.headers.Authorization).toBe(`Bearer ${TOKEN}`) + expect(init.headers["x-sm-source"]).toBe("supermemory-mcp") + expect(JSON.parse(init.body)).toEqual({ + page: 1, + limit: 200, + sort: "createdAt", + order: "desc", + containerTags: ["work"], + }) + }) + + it("uses a caller supplied abort signal and pagination", async () => { + const controller = new AbortController() + fetchMock.mockResolvedValue( + new Response(JSON.stringify(documentsPayload)), + ) + + await client("work").getDocuments(undefined, 2, 10, { + signal: controller.signal, + }) + + const init = fetchMock.mock.calls[0][1] + expect(init.signal).toBe(controller.signal) + expect(JSON.parse(init.body)).toEqual({ + page: 2, + limit: 10, + sort: "createdAt", + order: "desc", + }) + }) + + it("rejects a payload that does not match the API contract", async () => { + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ documents: [], pagination: {} })), + ) + + await expect(client("work").getDocuments()).rejects.toThrow() + }) + + it("lists SDK documents for the active space without content", async () => { + sdk.documents.list.mockResolvedValue({ + memories: [{ id: "doc_1" }], + pagination, + }) + + await expect(client("work").listDocuments()).resolves.toEqual({ + documents: [{ id: "doc_1" }], + pagination, + }) + expect(sdk.documents.list).toHaveBeenCalledWith({ + containerTags: ["work"], + page: 1, + limit: 50, + sort: "createdAt", + order: "desc", + includeContent: false, + }) + }) + + it("defaults to an empty list when the API omits memories", async () => { + sdk.documents.list.mockResolvedValue({ pagination }) + + await expect(client("work").listDocuments()).resolves.toEqual({ + documents: [], + pagination, + }) + }) + + it("translates a missing document without an operation prefix", async () => { + sdk.documents.get.mockRejectedValue(apiError("gone", 404)) + + await expect(client("work").getDocument("doc_1")).rejects.toThrow( + "Not found.", + ) + }) + }) + + describe("memory entries", () => { + it("lists memory entries for the active space", async () => { + const payload = { + memoryEntries: [ + { + id: "mem_1", + memory: "likes pizza", + version: 1, + isLatest: true, + isForgotten: false, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-02T00:00:00.000Z", + }, + ], + pagination, + } + fetchMock.mockResolvedValue(new Response(JSON.stringify(payload))) + + await expect(client("work").listMemoryEntries()).resolves.toEqual(payload) + + const [url, init] = fetchMock.mock.calls[0] + expect(url).toBe(`${API_URL}/v4/memories/list`) + expect(JSON.parse(init.body)).toEqual({ + containerTags: ["work"], + page: 1, + limit: 50, + sort: "createdAt", + order: "desc", + }) + }) + + it("surfaces the API message when the space is forbidden", async () => { + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ error: "Space is read-only" }), { + status: 403, + }), + ) + + await expect(client("work").listMemoryEntries()).rejects.toThrow( + "Space is read-only", + ) + }) + + it("falls back to scope guidance when the forbidden body is empty", async () => { + fetchMock.mockResolvedValue(new Response("", { status: 403 })) + + await expect(client("work").listMemoryEntries()).rejects.toThrow( + FORBIDDEN_FALLBACK, + ) + }) + }) + + describe("container tags", () => { + it("requests the list with auth and source headers", async () => { + const tag = { + id: "ct_1", + name: "Work", + containerTag: "work", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-02T00:00:00.000Z", + isExperimental: false, + isNova: false, + documentCount: 2, + memoryCount: 3, + } + fetchMock.mockResolvedValue(new Response(JSON.stringify([tag]))) + + await expect(client("work").listContainerTags()).resolves.toEqual([tag]) + + const [url, init] = fetchMock.mock.calls[0] + expect(url).toBe(`${API_URL}/v3/container-tags/list`) + expect(init.method).toBe("GET") + expect(init.headers.Authorization).toBe(`Bearer ${TOKEN}`) + expect(init.headers["x-sm-source"]).toBe("supermemory-mcp") + }) + + it("asks the user to re-authenticate on 401", async () => { + fetchMock.mockResolvedValue(new Response("", { status: 401 })) + + await expect(client().listContainerTags()).rejects.toThrow( + "Authentication failed. Please re-authenticate.", + ) + }) + + it("reports the status text for other failures", async () => { + fetchMock.mockResolvedValue( + new Response("", { status: 500, statusText: "Internal Server Error" }), + ) + + await expect(client().listContainerTags()).rejects.toThrow( + "Failed to fetch container tags: Internal Server Error", + ) + }) + + it("rejects a malformed payload", async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify([{ id: 1 }]))) + + await expect(client().listContainerTags()).rejects.toThrow() + }) + }) + + describe("error translation", () => { + const cases: [number, string, string][] = [ + [400, "", "Invalid request. Check your input."], + [ + 400, + JSON.stringify({ error: "page must be positive" }), + "page must be positive", + ], + [ + 401, + JSON.stringify({ error: "expired" }), + "Authentication failed. Please re-authenticate.", + ], + [402, "", "Memory limit reached. Upgrade at supermemory.ai"], + [403, "", FORBIDDEN_FALLBACK], + [403, JSON.stringify({ error: "Key is read-only" }), "Key is read-only"], + [ + 403, + JSON.stringify({ message: "Scoped to sm_project_x" }), + "Scoped to sm_project_x", + ], + [403, "plain text refusal", "plain text refusal"], + [403, JSON.stringify({ error: "" }), FORBIDDEN_FALLBACK], + [404, JSON.stringify({ error: "no such space" }), "Not found."], + [ + 422, + JSON.stringify({ error: "limit must be <= 200" }), + "limit must be <= 200", + ], + [429, "", "Rate limit exceeded. Please wait and try again."], + [500, "", "Server error. Please try again later."], + [ + 503, + JSON.stringify({ error: "upstream down" }), + "Server error. Please try again later.", + ], + [409, "", "Request failed with status 409."], + [409, JSON.stringify({ error: "already queued" }), "already queued"], + ] + + it.each( + cases, + )("maps HTTP %i with body '%s'", async (status, body, expected) => { + fetchMock.mockResolvedValue(new Response(body, { status })) + + await expect(client("work").getDocuments()).rejects.toThrow(expected) + }) + + it("reports an aborted or timed out request", async () => { + for (const name of ["AbortError", "TimeoutError"]) { + fetchMock.mockRejectedValue( + Object.assign(new Error("aborted"), { name }), + ) + + await expect(client().listContainerTags()).rejects.toThrow( + "Request to Supermemory API timed out", + ) + } + }) + + it("reports a failed network call", async () => { + fetchMock.mockRejectedValue(new TypeError("fetch failed")) + + await expect(client().listContainerTags()).rejects.toThrow( + "Network error. Please check your connection.", + ) + }) + + it("keeps unrelated type errors intact", async () => { + fetchMock.mockRejectedValue(new TypeError("value is not iterable")) + + await expect(client().listContainerTags()).rejects.toThrow( + "value is not iterable", + ) + }) + + it("wraps a thrown non-error value", async () => { + fetchMock.mockRejectedValue("kaboom") + + await expect(client().listContainerTags()).rejects.toThrow( + "Unexpected error: kaboom", + ) + }) + + it("labels failures with the operation that caused them", async () => { + sdk.add.mockRejectedValue(apiError("over quota", 402)) + await expect(client("work").createMemory("hi")).rejects.toThrow( + "Create memory request failed: Memory limit reached. Upgrade at supermemory.ai", + ) + + sdk.search.memories.mockRejectedValue(apiError("slow down", 429)) + await expect(client("work").search("query")).rejects.toThrow( + "Search request failed: Rate limit exceeded. Please wait and try again.", + ) + }) + }) + + describe("memory text", () => { + it("reads whichever text field the API returned", () => { + expect( + getMemoryText({ id: "a", memory: "remembered", similarity: 1 }), + ).toBe("remembered") + expect(getMemoryText({ id: "b", chunk: "chunked", similarity: 1 })).toBe( + "chunked", + ) + }) + }) +}) diff --git a/apps/mcp/src/server/client/index.ts b/apps/mcp/src/server/client/index.ts index ad9695a40..9232cc3cc 100644 --- a/apps/mcp/src/server/client/index.ts +++ b/apps/mcp/src/server/client/index.ts @@ -158,6 +158,7 @@ function extractApiErrorMessage(raw: unknown): string | undefined { if (typeof parsed.error === "string" && parsed.error) return parsed.error if (typeof parsed.message === "string" && parsed.message) return parsed.message + if (parsed && typeof parsed === "object") return undefined } catch {} return raw } @@ -501,6 +502,7 @@ export class SupermemoryClient { if (status >= 500) { throw new Error("Server error. Please try again later.") } + if (!message) throw new Error(`Request failed with status ${status}.`) } } From 53c005f776b7991bfc845cf71d203dc53e25d4b8 Mon Sep 17 00:00:00 2001 From: addyCooks Date: Thu, 20 Aug 2026 14:02:27 +0530 Subject: [PATCH 2/3] fix(web): recover a document title when auto-titling produces none Documents saved through the MCP connector have been landing with a null title, so every surface fell back to "Untitled Document". The titling step runs server-side, but the content itself almost always carries a usable title, and the ingest API has no title field for a caller to set. Resolve a display title from metadata.title, then the stored title, then the content itself (YAML frontmatter, a markdown heading, or a short opening line), and let add_memory pin a title through metadata. 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 +- .../components/brain-home/brain-home-view.tsx | 5 +- apps/web/components/dashboard-view.tsx | 3 +- .../components/document-cards/mcp-preview.tsx | 7 +- .../document-cards/note-preview.tsx | 7 +- apps/web/components/document-modal/index.tsx | 11 +- .../components/documents-command-palette.tsx | 6 +- apps/web/components/memories-grid.tsx | 7 +- apps/web/lib/document-title.test.ts | 178 ++++++++++++++++++ apps/web/lib/document-title.ts | 106 +++++++++++ 14 files changed, 455 insertions(+), 15 deletions(-) create mode 100644 apps/mcp/src/server/format.test.ts create mode 100644 apps/web/lib/document-title.test.ts create mode 100644 apps/web/lib/document-title.ts diff --git a/apps/mcp/src/server/client/index.test.ts b/apps/mcp/src/server/client/index.test.ts index 464c20807..da54ecb24 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 9232cc3cc..f9638826d 100644 --- a/apps/mcp/src/server/client/index.ts +++ b/apps/mcp/src/server/client/index.ts @@ -189,12 +189,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 000000000..4d923c5ee --- /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 c9bb3f11c..b35de6f5b 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 c708deb50..62a08390c 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", diff --git a/apps/web/components/brain-home/brain-home-view.tsx b/apps/web/components/brain-home/brain-home-view.tsx index 3501c5e8f..2d68b758f 100644 --- a/apps/web/components/brain-home/brain-home-view.tsx +++ b/apps/web/components/brain-home/brain-home-view.tsx @@ -12,6 +12,7 @@ import { TrialSetupBanner } from "@/components/trial-setup-banner" import { useTrialStatus } from "@/hooks/use-trial-status" import { dmSans125ClassName } from "@/lib/fonts" import { useViewMode } from "@/lib/view-mode-context" +import { resolveDocumentTitle } from "@/lib/document-title" import { AskInSlackCard, CONNECT_TOOLS_CARD_ID, @@ -31,6 +32,8 @@ const cardStyle = { type RecentDoc = { id?: string title?: string | null + content?: string | null + metadata?: Record | null createdAt?: string | Date | null updatedAt?: string | Date | null } @@ -390,7 +393,7 @@ function RecentMemories({

- {doc.title?.trim() || "Untitled memory"} + {resolveDocumentTitle(doc) || "Untitled memory"}

{formatWhen(doc.createdAt)} diff --git a/apps/web/components/dashboard-view.tsx b/apps/web/components/dashboard-view.tsx index 12c76a86f..90fa98bd3 100644 --- a/apps/web/components/dashboard-view.tsx +++ b/apps/web/components/dashboard-view.tsx @@ -45,6 +45,7 @@ import { normalizePluginClientId } from "@/lib/plugin-catalog" import { detectPluginSpace } from "@/lib/plugin-space" import { useDigests } from "@/hooks/use-digests" import { ReviewMemoriesCard } from "@/components/review-memories-card" +import { resolveDocumentTitle } from "@/lib/document-title" type DocumentsResponse = z.infer type DocumentWithMemories = DocumentsResponse["documents"][0] @@ -1599,7 +1600,7 @@ export function DashboardView({ )} - {doc.title?.trim() || "Untitled"} + {resolveDocumentTitle(doc) || "Untitled"} diff --git a/apps/web/components/document-cards/mcp-preview.tsx b/apps/web/components/document-cards/mcp-preview.tsx index 19cdeec4a..0e33a3453 100644 --- a/apps/web/components/document-cards/mcp-preview.tsx +++ b/apps/web/components/document-cards/mcp-preview.tsx @@ -6,6 +6,7 @@ import { dmSansClassName } from "@/lib/fonts" import { cn } from "@lib/utils" import { ClaudeDesktopIcon, MCPIcon } from "@ui/assets/icons" import type { ParsedPluginDocument } from "@/lib/plugin-document" +import { resolveDocumentTitle } from "@/lib/document-title" import { PluginPreview } from "./plugin-preview" type DocumentsResponse = z.infer @@ -28,6 +29,8 @@ export function McpPreview({ .replace(/\b\w/g, (match) => match.toUpperCase()) : "MCP Client" + const title = resolveDocumentTitle(document) + return (
@@ -43,9 +46,9 @@ export function McpPreview({
- {document.title && ( + {title && (

- {document.title} + {title}

)} {document.content && ( diff --git a/apps/web/components/document-cards/note-preview.tsx b/apps/web/components/document-cards/note-preview.tsx index e96234976..8de965cac 100644 --- a/apps/web/components/document-cards/note-preview.tsx +++ b/apps/web/components/document-cards/note-preview.tsx @@ -6,6 +6,7 @@ import { dmSansClassName } from "@/lib/fonts" import { cn } from "@lib/utils" import { DocumentIcon } from "@/components/document-icon" import type { ParsedPluginDocument } from "@/lib/plugin-document" +import { resolveDocumentTitle } from "@/lib/document-title" import { PluginPreview } from "./plugin-preview" type DocumentsResponse = z.infer @@ -22,6 +23,8 @@ export function NotePreview({ return } + const title = resolveDocumentTitle(document) + return (
@@ -31,14 +34,14 @@ export function NotePreview({

- {document.title && ( + {title && (

- {document.title} + {title}

)} {document.summary && ( diff --git a/apps/web/components/document-modal/index.tsx b/apps/web/components/document-modal/index.tsx index 77d808261..e74d6f977 100644 --- a/apps/web/components/document-modal/index.tsx +++ b/apps/web/components/document-modal/index.tsx @@ -27,6 +27,7 @@ import type { UseMutationResult } from "@tanstack/react-query" import { toast } from "sonner" import { useIsMobile } from "@hooks/use-mobile" import { parsePluginDocument } from "@/lib/plugin-document" +import { resolveDocumentTitle } from "@/lib/document-title" import { useFullDocumentContent } from "@/hooks/use-full-document" type DocumentsResponse = z.infer @@ -219,6 +220,10 @@ export function DocumentModal({ () => parsePluginDocument(effectiveDocument), [effectiveDocument], ) + const resolvedTitle = useMemo( + () => resolveDocumentTitle(effectiveDocument), + [effectiveDocument], + ) const [draftContentString, setDraftContentString] = useState(initialEditorString) @@ -330,17 +335,17 @@ export function DocumentModal({ <> {isMobile ? ( - {_document?.title} - Document + {resolvedTitle} - Document ) : ( - {_document?.title} - Document + {resolvedTitle} - Document )}
type DocumentWithMemories = DocumentsResponse["documents"][0] @@ -282,7 +283,10 @@ export function DocumentsCommandPalette({ ) } - const title = item.kind === "document" ? item.doc.title : item.result.title + const title = + item.kind === "document" + ? resolveDocumentTitle(item.doc) + : item.result.title const type = item.kind === "document" ? item.doc.type : item.result.type const url = item.kind === "document" diff --git a/apps/web/components/memories-grid.tsx b/apps/web/components/memories-grid.tsx index beaee36cc..11a87197c 100644 --- a/apps/web/components/memories-grid.tsx +++ b/apps/web/components/memories-grid.tsx @@ -29,6 +29,7 @@ import { YoutubePreview } from "./document-cards/youtube-preview" import { getAbsoluteUrl, isYouTubeUrl, useYouTubeChannelName } from "./utils" import { SyncLogoIcon } from "@ui/assets/icons" import { McpPreview } from "./document-cards/mcp-preview" +import { resolveDocumentTitle } from "@/lib/document-title" import { NotionPreview } from "./document-cards/notion-preview" import { getFaviconUrl, isSupermemoryFileUrl } from "@/lib/url-helpers" import { QuickNoteCard } from "./quick-note-card" @@ -1143,6 +1144,10 @@ const DocumentCard = memo( () => parsePluginDocument(document), [document], ) + const resolvedTitle = useMemo( + () => resolveDocumentTitle(document), + [document], + ) const [rotation, setRotation] = useState({ rotateX: 0, rotateY: 0 }) const cardRef = useRef<HTMLButtonElement>(null) const [ogData, setOgData] = useState<OgData | null>(null) @@ -1298,7 +1303,7 @@ const DocumentCard = memo( "text-[13px] text-[#E5E5E5] line-clamp-1 font-semibold", )} > - {document.title || ogData?.title || "Untitled Document"} + {resolvedTitle || ogData?.title || "Untitled Document"} </p> {getFaviconUrl(document.url) && needsOgData && ( <img diff --git a/apps/web/lib/document-title.test.ts b/apps/web/lib/document-title.test.ts new file mode 100644 index 000000000..47c43151b --- /dev/null +++ b/apps/web/lib/document-title.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it } from "bun:test" +import { resolveDocumentTitle } from "./document-title" + +const fromContent = (content: string | null | undefined) => + resolveDocumentTitle({ content }) + +describe("resolveDocumentTitle precedence", () => { + it("prefers metadata.title over everything", () => { + expect( + resolveDocumentTitle({ + title: "LLM paraphrase", + metadata: { title: "Pinned" }, + content: "# Derived", + }), + ).toBe("Pinned") + }) + + it("falls back to the stored title", () => { + expect( + resolveDocumentTitle({ title: "Stored", content: "# Derived" }), + ).toBe("Stored") + }) + + it("derives from content when titling produced nothing", () => { + expect(resolveDocumentTitle({ title: null, content: "# Derived" })).toBe( + "Derived", + ) + }) + + it("skips blank and non-string candidates", () => { + expect(resolveDocumentTitle({ title: " ", content: "# Derived" })).toBe( + "Derived", + ) + expect( + resolveDocumentTitle({ metadata: { title: " " }, content: "# Derived" }), + ).toBe("Derived") + expect( + resolveDocumentTitle({ metadata: { title: 42 }, content: "# Derived" }), + ).toBe("Derived") + expect( + resolveDocumentTitle({ metadata: { title: null }, title: "Stored" }), + ).toBe("Stored") + }) + + it("survives odd metadata shapes", () => { + expect(resolveDocumentTitle({ metadata: null, title: "Stored" })).toBe( + "Stored", + ) + expect( + resolveDocumentTitle({ + metadata: [] as unknown as Record<string, unknown>, + title: "Stored", + }), + ).toBe("Stored") + }) + + it("returns null when there is nothing to show", () => { + expect(resolveDocumentTitle(null)).toBeNull() + expect(resolveDocumentTitle(undefined)).toBeNull() + expect(resolveDocumentTitle({})).toBeNull() + expect(resolveDocumentTitle({ title: null, content: null })).toBeNull() + }) +}) + +describe("deriving a title from content", () => { + it("reads markdown headings at every level", () => { + expect(fromContent("# Quarterly planning\n\nProse.")).toBe( + "Quarterly planning", + ) + expect(fromContent("###### Deep heading\n\nProse.")).toBe("Deep heading") + }) + + it("drops closing hashes and surrounding markup", () => { + expect(fromContent("### Deploy runbook ###\n\nSteps.")).toBe( + "Deploy runbook", + ) + expect(fromContent("# **Bold heading**\n\nProse.")).toBe("Bold heading") + expect(fromContent("**Bold line**\n\nProse.")).toBe("Bold line") + expect(fromContent("`code line`\n\nProse.")).toBe("code line") + expect(fromContent('"Quoted line"\n\nProse.')).toBe("Quoted line") + }) + + it("requires a space after the hashes", () => { + expect(fromContent("#NotAHeading\n\nProse.")).toBe("#NotAHeading") + }) + + it("reads a YAML frontmatter title", () => { + expect( + fromContent( + '---\ntitle: "Kubernetes upgrade"\ntags: [infra]\n---\n\nBody.', + ), + ).toBe("Kubernetes upgrade") + expect(fromContent("---\ntitle: 'Single quoted'\n---\nBody.")).toBe( + "Single quoted", + ) + }) + + it("prefers frontmatter over a following heading", () => { + expect(fromContent("---\ntitle: Real\n---\n\n# Other")).toBe("Real") + }) + + it("falls through when frontmatter has no usable title", () => { + expect(fromContent("---\ntags: [infra]\n---\n\n# Heading wins")).toBe( + "Heading wins", + ) + expect(fromContent("---\ntitle:\n---\n\n# Heading wins")).toBe( + "Heading wins", + ) + }) + + it("ignores an indented title key inside frontmatter", () => { + expect(fromContent("---\nauthor:\n title: Nested\n---\n\n# Heading")).toBe( + "Heading", + ) + }) + + it("takes a short opening line followed by prose", () => { + expect( + fromContent("Postgres connection pooling\n\nWe moved to pgbouncer."), + ).toBe("Postgres connection pooling") + }) + + it("takes a setext heading regardless of length", () => { + const long = `${"Long ".repeat(40)}heading` + expect(fromContent(`${long}\n===\n\nBody.`)).toStartWith("Long") + expect(fromContent("Underlined\n---\n\nBody.")).toBe("Underlined") + }) + + it("rejects an opening paragraph too long to be a title", () => { + expect( + fromContent("This is ordinary prose that keeps going. ".repeat(6)), + ).toBeNull() + }) + + it("rejects list, quote, table, rule, fence and URL openers", () => { + expect(fromContent("- first\n- second")).toBeNull() + expect(fromContent("* first\n* second")).toBeNull() + expect(fromContent("1. first\n2. second")).toBeNull() + expect(fromContent("> quoted\n\nmore")).toBeNull() + expect(fromContent("| a | b |\n| - | - |")).toBeNull() + expect(fromContent("---\n\nnot frontmatter")).toBeNull() + expect(fromContent("```ts\nconst a = 1\n```")).toBeNull() + expect(fromContent("~~~\ncode\n~~~")).toBeNull() + expect(fromContent("https://example.com/article")).toBeNull() + expect(fromContent("www.example.com/article")).toBeNull() + }) + + it("skips leading blank lines", () => { + expect(fromContent("\n\n\n# After blanks\n\nProse.")).toBe("After blanks") + }) + + it("handles CRLF, a BOM and collapsed whitespace", () => { + expect(fromContent("# Spaced out\r\n\r\nBody.")).toBe("Spaced out") + expect(fromContent("\ufeff---\r\ntitle: From BOM\r\n---\r\nBody.")).toBe( + "From BOM", + ) + expect(fromContent("Tabbed\ttitle\n\nBody.")).toBe("Tabbed title") + }) + + it("truncates an overlong heading to a bounded length", () => { + const title = fromContent(`# ${"word ".repeat(60)}`) + expect(title).not.toBeNull() + expect((title as string).length).toBeLessThanOrEqual(120) + expect(title).toEndWith("…") + }) + + it("returns null for empty, blank or missing content", () => { + expect(fromContent("")).toBeNull() + expect(fromContent(" \n\n ")).toBeNull() + expect(fromContent("#\n\nBody.")).toBeNull() + expect(fromContent(null)).toBeNull() + expect(fromContent(undefined)).toBeNull() + }) + + it("handles a single-line document with no trailing newline", () => { + expect(fromContent("Just one line")).toBe("Just one line") + }) +}) diff --git a/apps/web/lib/document-title.ts b/apps/web/lib/document-title.ts new file mode 100644 index 000000000..2328b1e02 --- /dev/null +++ b/apps/web/lib/document-title.ts @@ -0,0 +1,106 @@ +type TitleSource = { + title?: string | null + content?: string | null + metadata?: Record<string, unknown> | null +} + +const MAX_TITLE_CHARS = 120 +const FRONTMATTER = /^\ufeff?---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/ +const FRONTMATTER_TITLE = /^title[ \t]*:[ \t]*(.+)$/m +const ATX_HEADING = /^#{1,6}\s+(.*?)\s*#*$/ +const SETEXT_UNDERLINE = /^(?:=+|-{2,})$/ +const HORIZONTAL_RULE = /^(?:-{3,}|\*{3,}|_{3,})$/ +const BLOCK_MARKER = /^(?:[-*+]\s|>\s?|\d+[.)]\s|\|)/ +const BARE_URL = /^(?:https?:\/\/|www\.)\S+$/i +const WORD = /[\p{L}\p{N}]/u +const WRAPPERS = ["***", "**", "__", "*", "_", "`"] + +function collapse(value: string): string { + return value.replace(/\s+/g, " ").trim() +} + +function clamp(value: string): string | null { + if (!value) return null + return value.length <= MAX_TITLE_CHARS + ? value + : `${value.slice(0, MAX_TITLE_CHARS - 1).trimEnd()}…` +} + +function unwrap(value: string): string { + let text = value.trim() + for (const marker of WRAPPERS) { + while ( + text.length > marker.length * 2 && + text.startsWith(marker) && + text.endsWith(marker) + ) { + text = text.slice(marker.length, -marker.length).trim() + } + } + const quote = text[0] + if ( + text.length >= 2 && + (quote === '"' || quote === "'") && + text.endsWith(quote) + ) { + text = text.slice(1, -1).trim() + } + return text +} + +function fromContent(content: string): string | null { + const frontmatter = FRONTMATTER.exec(content) + const declared = frontmatter?.[1] + ? FRONTMATTER_TITLE.exec(frontmatter[1])?.[1] + : undefined + if (declared) { + const title = clamp(collapse(unwrap(declared))) + if (title) return title + } + + const body = frontmatter ? content.slice(frontmatter[0].length) : content + const lines = body.split(/\r?\n/) + const start = lines.findIndex((line) => line.trim().length > 0) + if (start === -1) return null + + const first = (lines[start] ?? "").trim() + if ( + first.startsWith("```") || + first.startsWith("~~~") || + BARE_URL.test(first) || + HORIZONTAL_RULE.test(first) + ) { + return null + } + + const heading = ATX_HEADING.exec(first) + if (heading) return clamp(collapse(unwrap(heading[1] ?? ""))) + if (BLOCK_MARKER.test(first)) return null + + const candidate = collapse(unwrap(first)) + if (!WORD.test(candidate)) return null + const underlined = SETEXT_UNDERLINE.test(lines[start + 1]?.trim() ?? "") + if (!underlined && candidate.length > MAX_TITLE_CHARS) return null + return clamp(candidate) +} + +export function resolveDocumentTitle( + document: TitleSource | null | undefined, +): string | null { + if (!document) return null + + const metadata = document.metadata + const pinned = + metadata && typeof metadata === "object" ? metadata.title : undefined + + for (const candidate of [pinned, document.title]) { + if (typeof candidate === "string") { + const title = clamp(collapse(candidate)) + if (title) return title + } + } + + return typeof document.content === "string" + ? fromContent(document.content) + : null +} From e62ef1a40bb694044c3665afd2c49c6899187f70 Mon Sep 17 00:00:00 2001 From: addyCooks <adityadevansh2002@gmail.com> Date: Thu, 20 Aug 2026 14:02:27 +0530 Subject: [PATCH 3/3] test(web): cover the saves reported in #1425 Pin the three content shapes from the issue - a markdown H1, a YAML frontmatter title block, and a title line followed by prose - plus repairing a card by pinning metadata.title over a null or paraphrased title. --- apps/web/lib/document-title.test.ts | 51 +++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/apps/web/lib/document-title.test.ts b/apps/web/lib/document-title.test.ts index 47c43151b..9d2fd70dd 100644 --- a/apps/web/lib/document-title.test.ts +++ b/apps/web/lib/document-title.test.ts @@ -176,3 +176,54 @@ describe("deriving a title from content", () => { expect(fromContent("Just one line")).toBe("Just one line") }) }) + +describe("issue #1425 saves", () => { + const cases = [ + [ + "markdown H1", + "# Kubernetes upgrade plan\n\nWe are moving the cluster to 1.31.", + "Kubernetes upgrade plan", + ], + [ + "YAML frontmatter", + "---\ntitle: Postgres pooling decision\ndate: 2026-08-07\n---\n\nWe moved to pgbouncer.", + "Postgres pooling decision", + ], + [ + "title line then blank line then prose", + "Vendor security review\n\nThey passed SOC2 but the DPA needs redlines.", + "Vendor security review", + ], + ] as const + + for (const [label, content, expected] of cases) { + it(`${label} no longer reads as untitled`, () => { + const doc = { + title: null, + content, + metadata: { sm_source: "supermemory-mcp" }, + } + expect(resolveDocumentTitle(doc)).toBe(expected) + }) + } + + it("a pinned title repairs a card without re-saving", () => { + expect( + resolveDocumentTitle({ + title: null, + content: cases[0][1], + metadata: { sm_source: "supermemory-mcp", title: "My chosen title" }, + }), + ).toBe("My chosen title") + }) + + it("a pinned title beats an LLM paraphrase", () => { + expect( + resolveDocumentTitle({ + title: "Notes About Upgrading Some Infrastructure", + content: cases[0][1], + metadata: { title: "Kubernetes upgrade plan" }, + }), + ).toBe("Kubernetes upgrade plan") + }) +})