fix chatgpt memory graph rendering (#1393)

ChatGPT can omit nullable nested fields from structured tool results and does not reliably expose result metadata to MCP Apps. Keep graph data in `structuredContent`, tolerate missing document titles, and bundle React inside the widget so the graph renders consistently across hosts.

- use the deployable `app-v4` resource URI
- keep the graph response and widget schema aligned
- remove the `esm.sh` runtime dependency

Tested with typecheck, 42 unit tests, production widget build, 36 branch-local authenticated E2E tests, and a live ChatGPT graph render.
This commit is contained in:
Prasanna721 2026-07-31 21:20:15 +00:00
parent 9e194fbc50
commit 48fb1969f8
10 changed files with 26 additions and 91 deletions

View file

@ -82,7 +82,7 @@ These tools are available to the embedded MCP App and hidden from the model.
| --- | --- | --- |
| Resource | `supermemory://profile` | Profile facts in the effective space |
| Resource | `supermemory://spaces` | Visible spaces |
| Resource | `ui://supermemory/app-v3.html` | Embedded MCP App bundle |
| Resource | `ui://supermemory/app-v4.html` | Embedded MCP App bundle |
| Prompt | `context` | Profile and recent context for an optional space |
The App resource and tool metadata include both current nested `ui` metadata and

View file

@ -19,16 +19,14 @@ describeWithAuth("MCP — graph, resources & prompts", () => {
await s?.close()
})
it("memory-graph returns a rendered widget summary", async () => {
it("memory-graph returns a rendered graph widget", async () => {
const res = await callTool(s.client, "memory-graph")
expect(res.isError).toBeFalsy()
expect(textOf(res)).toMatch(
/The interactive Memory Graph MCP App is rendered and visible: \d+ documents/,
)
expect(textOf(res)).toMatch(/Rendered the interactive Memory Graph MCP App/)
const result = graphViewSchema.safeParse(res.structuredContent)
expect(result.success).toBe(true)
if (!result.success) throw result.error
expect(result.data.rendered).toBe(true)
expect(result.data.view).toBe("graph")
})
it("fetch-graph-data returns paginated documents", async () => {

View file

@ -4,13 +4,12 @@ import { SUPERMEMORY_RESOURCE_URI } from "../../shared/types"
import { APP_RESOURCE_MIME_TYPE } from "../app-metadata"
const CSP_DOMAINS = [
"https://esm.sh",
"https://fonts.googleapis.com",
"https://fonts.gstatic.com",
]
const WIDGET_DESCRIPTION =
"Interactive Supermemory view. The tool result identifies whether the mounted app is a memory graph, space picker, save form, upload form, or confirmation. When rendered is true, the interface is already visible to the user."
"Interactive Supermemory view for memory graphs, space selection, guided saves, file uploads, and confirmations."
const RESOURCE_UI_META = {
prefersBorder: true,

View file

@ -31,7 +31,12 @@ export function register(deps: ToolDeps) {
)
return {
content: [{ type: "text" as const, text: JSON.stringify(data) }],
content: [
{
type: "text" as const,
text: `Loaded ${data.documents.length} documents for the memory graph.`,
},
],
structuredContent: data,
}
} catch (error) {

View file

@ -27,37 +27,28 @@ export function register(deps: ToolDeps) {
const effectiveTag = await deps.resolveContainerTag(args.containerTag)
const client = deps.getClient(effectiveTag)
const containerTags = effectiveTag ? [effectiveTag] : undefined
const result = await client.getDocuments(containerTags, 1, 200)
const memoryCount = result.documents.reduce(
(sum, d) => sum + d.memoryEntries.length,
(sum, document) => sum + document.memoryEntries.length,
0,
)
const sc: ViewMessage = {
view: "graph",
viewId,
...(effectiveTag ? { containerTag: effectiveTag } : {}),
documentCount: result.documents.length,
memoryCount,
totalDocumentCount: result.pagination.totalItems,
truncated: result.documents.length < result.pagination.totalItems,
rendered: true,
documents: result.documents,
totalCount: result.pagination.totalItems,
}
return {
content: [
{
type: "text" as const,
text: `The interactive Memory Graph MCP App is rendered and visible: ${result.documents.length} documents, ${memoryCount} memories${effectiveTag ? `. Space: ${effectiveTag}` : ""}. Do not create a duplicate graph or artifact unless the user explicitly requests one.`,
text: `Rendered the interactive Memory Graph MCP App: ${result.documents.length} documents, ${memoryCount} memories${effectiveTag ? `. Space: ${effectiveTag}` : ""}. Do not create a duplicate graph or artifact unless the user explicitly requests one.`,
},
],
structuredContent: sc,
_meta: {
...appResultMeta(viewId),
graphData: { documents: result.documents },
},
_meta: appResultMeta(viewId),
}
} catch (error) {
return deps.errorResult(error)

View file

@ -92,7 +92,7 @@ export type DocumentMemoryEntry = z.infer<typeof documentMemoryEntrySchema>
export const documentWithMemoriesSchema = z.looseObject({
id: z.string(),
title: z.string().nullable(),
title: z.string().nullish(),
summary: z.string().nullish(),
type: z.string(),
createdAt: z.string(),
@ -169,11 +169,8 @@ export const graphViewSchema = z.object({
view: z.literal("graph"),
viewId: viewIdSchema,
containerTag: z.string().optional(),
documentCount: z.number().int().nonnegative(),
memoryCount: z.number().int().nonnegative(),
totalDocumentCount: z.number().int().nonnegative(),
truncated: z.boolean(),
rendered: z.literal(true),
documents: z.array(documentWithMemoriesSchema),
totalCount: z.number().int().nonnegative(),
})
export const viewMessageSchema = z.discriminatedUnion("view", [
@ -188,15 +185,7 @@ export const viewMessageSchema = z.discriminatedUnion("view", [
export type ViewMessage = z.infer<typeof viewMessageSchema>
export const graphResultMetaSchema = z.looseObject({
graphData: z.object({
documents: z.array(documentWithMemoriesSchema),
}),
})
export type GraphResultMeta = z.infer<typeof graphResultMetaSchema>
export type ViewName = ViewMessage["view"]
// Hosts cache MCP UI resources by URI, so bump this when shipping a new widget bundle.
export const SUPERMEMORY_RESOURCE_URI = "ui://supermemory/app-v3.html"
export const SUPERMEMORY_RESOURCE_URI = "ui://supermemory/app-v4.html"

View file

@ -1,5 +1,5 @@
import { type ReactNode, useEffect } from "react"
import type { GraphResultMeta, ViewMessage } from "../shared/types"
import type { ViewMessage } from "../shared/types"
import { useApplyHostTheme } from "./hooks/useApplyHostTheme"
import { useLog } from "./hooks/useLog"
import { useViewState } from "./hooks/useViewState"
@ -50,7 +50,7 @@ export function App() {
const isGraphView = state.message.view === "graph"
return (
<WidgetShell immersive={isGraphView}>
{renderView(state.message, state.resultMeta, setView, setError)}
{renderView(state.message, setView, setError)}
</WidgetShell>
)
}
@ -93,7 +93,6 @@ export function WidgetShell({
function renderView(
msg: ViewMessage,
resultMeta: GraphResultMeta | undefined,
setView: (m: ViewMessage) => void,
setError: (m: string) => void,
) {
@ -131,16 +130,7 @@ function renderView(
/>
)
case "graph":
if (!resultMeta) {
return <ErrorView message="Memory graph data is unavailable." />
}
return (
<Graph
containerTag={msg.containerTag}
documents={resultMeta.graphData.documents}
totalCount={msg.totalDocumentCount}
/>
)
return <Graph documents={msg.documents} totalCount={msg.totalCount} />
case "confirmation":
return <Confirmation containerTag={msg.containerTag} />
case "save-success":

View file

@ -10,17 +10,12 @@ import {
useMemo,
useState,
} from "react"
import {
graphResultMetaSchema,
type GraphResultMeta,
viewMessageSchema,
type ViewMessage,
} from "../shared/types"
import { viewMessageSchema, type ViewMessage } from "../shared/types"
import { loadViewCheckpoint, saveViewCheckpoint } from "./lib/viewCheckpoint"
export type ViewState =
| { kind: "loading" }
| { kind: "view"; message: ViewMessage; resultMeta?: GraphResultMeta }
| { kind: "view"; message: ViewMessage }
| { kind: "error"; message: string }
| { kind: "raw"; structuredContent: unknown }
@ -95,23 +90,6 @@ export function McpAppProvider({ children }: { children: ReactNode }) {
return
}
if (message.view === "graph") {
const parsedMeta = graphResultMetaSchema.safeParse(result._meta)
if (!parsedMeta.success) {
setState({
kind: "error",
message: "Memory graph data is unavailable.",
})
return
}
setState({
kind: "view",
message,
resultMeta: parsedMeta.data,
})
return
}
setState({ kind: "view", message })
}
createdApp.onhostcontextchanged = (next) => {

View file

@ -83,7 +83,6 @@ function useGraphColors(theme: string): GraphThemeColors {
interface Props {
documents: DocumentWithMemories[]
totalCount: number
containerTag?: string
}
// Map the widget's API shape (DocumentWithMemories) into the package's
@ -115,7 +114,7 @@ function toGraphMemory(mem: DocumentMemoryEntry): GraphApiMemory {
function toGraphDocument(doc: DocumentWithMemories): GraphApiDocument {
return {
id: doc.id,
title: doc.title,
title: doc.title ?? null,
summary: doc.summary ?? null,
documentType: doc.type,
createdAt: doc.createdAt,

View file

@ -3,11 +3,6 @@ import react from "@vitejs/plugin-react"
import { defineConfig } from "vite"
import { viteSingleFile } from "vite-plugin-singlefile"
// PROD config: single-file HTML bundle for the MCP widget resource.
// React/React-DOM are externalized to esm.sh — keeps the bundle small and
// makes the host fetch them from a CDN. The widget resource declares
// `_meta.ui.csp.resourceDomains: ["https://esm.sh"]` so the host allows it.
// Tailwind 4 generates CSS at build time and is inlined by viteSingleFile.
export default defineConfig({
plugins: [tailwindcss(), react(), viteSingleFile()],
build: {
@ -15,15 +10,6 @@ export default defineConfig({
emptyOutDir: false,
rollupOptions: {
input: "src/widget/index.html",
external: ["react", "react-dom", "react-dom/client", "react/jsx-runtime"],
output: {
paths: {
react: "https://esm.sh/react@19.2.4",
"react-dom": "https://esm.sh/react-dom@19.2.4",
"react-dom/client": "https://esm.sh/react-dom@19.2.4/client",
"react/jsx-runtime": "https://esm.sh/react@19.2.4/jsx-runtime",
},
},
},
},
})