Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
694 changes: 694 additions & 0 deletions apps/mcp/src/server/client/index.test.ts

Large diffs are not rendered by default.

9 changes: 8 additions & 1 deletion apps/mcp/src/server/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -188,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,
Expand Down Expand Up @@ -501,6 +507,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}.`)
}
}

Expand Down
76 changes: 76 additions & 0 deletions apps/mcp/src/server/format.test.ts
Original file line number Diff line number Diff line change
@@ -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"')
}
})
})
16 changes: 14 additions & 2 deletions apps/mcp/src/server/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)})`,
]
Expand Down Expand Up @@ -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}`,
Expand Down
13 changes: 12 additions & 1 deletion apps/mcp/src/server/tools/add-memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})

Expand Down Expand Up @@ -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",
Expand Down
5 changes: 4 additions & 1 deletion apps/web/components/brain-home/brain-home-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -31,6 +32,8 @@ const cardStyle = {
type RecentDoc = {
id?: string
title?: string | null
content?: string | null
metadata?: Record<string, unknown> | null
createdAt?: string | Date | null
updatedAt?: string | Date | null
}
Expand Down Expand Up @@ -390,7 +393,7 @@ function RecentMemories({
<FileText className="size-3.5" />
</div>
<p className="min-w-0 flex-1 truncate text-[13px] font-medium text-[#fafafa]">
{doc.title?.trim() || "Untitled memory"}
{resolveDocumentTitle(doc) || "Untitled memory"}
</p>
<span className="shrink-0 text-[11px] font-medium text-[#737373]">
{formatWhen(doc.createdAt)}
Expand Down
3 changes: 2 additions & 1 deletion apps/web/components/dashboard-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
Expand Down Expand Up @@ -1599,7 +1600,7 @@ export function DashboardView({
)}
</div>
<span className="min-w-0 flex-1 truncate text-sm text-fg-muted group-hover:text-white transition-colors">
{doc.title?.trim() || "Untitled"}
{resolveDocumentTitle(doc) || "Untitled"}
</span>
<ArrowRight className="size-3.5 shrink-0 text-fg-faint group-hover:text-fg-muted transition-colors" />
</button>
Expand Down
7 changes: 5 additions & 2 deletions apps/web/components/document-cards/mcp-preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof DocumentsWithMemoriesResponseSchema>
Expand All @@ -28,6 +29,8 @@ export function McpPreview({
.replace(/\b\w/g, (match) => match.toUpperCase())
: "MCP Client"

const title = resolveDocumentTitle(document)

return (
<div className="bg-[#0B1017] p-3 rounded-[18px] space-y-2">
<div className="flex items-center justify-between gap-1">
Expand All @@ -43,9 +46,9 @@ export function McpPreview({
<MCPIcon className="size-6" />
</div>
<div className="space-y-[6px]">
{document.title && (
{title && (
<p className={cn(dmSansClassName(), "text-[13px] font-semibold")}>
{document.title}
{title}
</p>
)}
{document.content && (
Expand Down
7 changes: 5 additions & 2 deletions apps/web/components/document-cards/note-preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof DocumentsWithMemoriesResponseSchema>
Expand All @@ -22,6 +23,8 @@ export function NotePreview({
return <PluginPreview parsed={parsed} />
}

const title = resolveDocumentTitle(document)

return (
<div className="bg-[#0B1017] p-3 rounded-[18px] space-y-2">
<div className="flex items-center gap-1">
Expand All @@ -31,14 +34,14 @@ export function NotePreview({
</p>
</div>
<div>
{document.title && (
{title && (
<p
className={cn(
dmSansClassName(),
"text-[13px] font-semibold line-clamp-2 leading-[125%]",
)}
>
{document.title}
{title}
</p>
)}
{document.summary && (
Expand Down
11 changes: 8 additions & 3 deletions apps/web/components/document-modal/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof DocumentsWithMemoriesResponseSchema>
Expand Down Expand Up @@ -219,6 +220,10 @@ export function DocumentModal({
() => parsePluginDocument(effectiveDocument),
[effectiveDocument],
)
const resolvedTitle = useMemo(
() => resolveDocumentTitle(effectiveDocument),
[effectiveDocument],
)

const [draftContentString, setDraftContentString] =
useState(initialEditorString)
Expand Down Expand Up @@ -330,17 +335,17 @@ export function DocumentModal({
<>
{isMobile ? (
<DrawerTitle className="sr-only">
{_document?.title} - Document
{resolvedTitle} - Document
</DrawerTitle>
) : (
<DialogTitle className="sr-only">
{_document?.title} - Document
{resolvedTitle} - Document
</DialogTitle>
)}
<div className="flex items-center justify-between h-fit gap-2 md:gap-4">
<div className="flex-1 min-w-0">
<Title
title={_document?.title}
title={resolvedTitle}
documentType={_document?.type ?? "text"}
url={_document?.url}
pluginIconSrc={pluginDocument?.pluginIconSrc}
Expand Down
6 changes: 5 additions & 1 deletion apps/web/components/documents-command-palette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import { DocumentIcon } from "@/components/document-icon"
import { useSettingsModal } from "@/components/settings/settings-modal"
import { $fetch } from "@lib/api"
import { resolveDocumentTitle } from "@/lib/document-title"

type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
Expand Down Expand Up @@ -252,7 +253,7 @@
if (item) handleSelect(item)
}
},
[items, selectedIndex, handleSelect],

Check warning on line 256 in apps/web/components/documents-command-palette.tsx

View workflow job for this annotation

GitHub Actions / Quality Checks

lint/correctness/useExhaustiveDependencies

items changes on every re-render and should not be used as a hook dependency.
)

function renderItem(item: PaletteItem, index: number) {
Expand Down Expand Up @@ -282,7 +283,10 @@
)
}

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"
Expand Down
7 changes: 6 additions & 1 deletion apps/web/components/memories-grid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading