From 48fb1969f8812c98b4153c0b2d67d33a71e7e7c0 Mon Sep 17 00:00:00 2001 From: Prasanna721 <106952318+Prasanna721@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:20:15 +0000 Subject: [PATCH] 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. --- apps/mcp/README.md | 2 +- apps/mcp/e2e/graph.test.ts | 8 +++--- apps/mcp/src/server/resources/widget.ts | 3 +-- apps/mcp/src/server/tools/fetch-graph-data.ts | 7 ++++- apps/mcp/src/server/tools/memory-graph.ts | 19 ++++---------- apps/mcp/src/shared/types.ts | 19 +++----------- apps/mcp/src/widget/App.tsx | 16 +++--------- apps/mcp/src/widget/McpAppProvider.tsx | 26 ++----------------- apps/mcp/src/widget/views/Graph.tsx | 3 +-- apps/mcp/vite.config.ts | 14 ---------- 10 files changed, 26 insertions(+), 91 deletions(-) diff --git a/apps/mcp/README.md b/apps/mcp/README.md index 0bf5d374..a8325482 100644 --- a/apps/mcp/README.md +++ b/apps/mcp/README.md @@ -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 diff --git a/apps/mcp/e2e/graph.test.ts b/apps/mcp/e2e/graph.test.ts index b8811eb4..5a231e5f 100644 --- a/apps/mcp/e2e/graph.test.ts +++ b/apps/mcp/e2e/graph.test.ts @@ -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 () => { diff --git a/apps/mcp/src/server/resources/widget.ts b/apps/mcp/src/server/resources/widget.ts index 2c8ba892..69f2b646 100644 --- a/apps/mcp/src/server/resources/widget.ts +++ b/apps/mcp/src/server/resources/widget.ts @@ -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, diff --git a/apps/mcp/src/server/tools/fetch-graph-data.ts b/apps/mcp/src/server/tools/fetch-graph-data.ts index 4ecd35c1..7d115356 100644 --- a/apps/mcp/src/server/tools/fetch-graph-data.ts +++ b/apps/mcp/src/server/tools/fetch-graph-data.ts @@ -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) { diff --git a/apps/mcp/src/server/tools/memory-graph.ts b/apps/mcp/src/server/tools/memory-graph.ts index 69b71a27..70d1949f 100644 --- a/apps/mcp/src/server/tools/memory-graph.ts +++ b/apps/mcp/src/server/tools/memory-graph.ts @@ -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) diff --git a/apps/mcp/src/shared/types.ts b/apps/mcp/src/shared/types.ts index f17d52eb..fb063491 100644 --- a/apps/mcp/src/shared/types.ts +++ b/apps/mcp/src/shared/types.ts @@ -92,7 +92,7 @@ export type DocumentMemoryEntry = z.infer 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 -export const graphResultMetaSchema = z.looseObject({ - graphData: z.object({ - documents: z.array(documentWithMemoriesSchema), - }), -}) - -export type GraphResultMeta = z.infer - 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" diff --git a/apps/mcp/src/widget/App.tsx b/apps/mcp/src/widget/App.tsx index 1d1774ac..d0a7e2df 100644 --- a/apps/mcp/src/widget/App.tsx +++ b/apps/mcp/src/widget/App.tsx @@ -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 ( - {renderView(state.message, state.resultMeta, setView, setError)} + {renderView(state.message, setView, setError)} ) } @@ -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 - } - return ( - - ) + return case "confirmation": return case "save-success": diff --git a/apps/mcp/src/widget/McpAppProvider.tsx b/apps/mcp/src/widget/McpAppProvider.tsx index f17299bd..8fa33a93 100644 --- a/apps/mcp/src/widget/McpAppProvider.tsx +++ b/apps/mcp/src/widget/McpAppProvider.tsx @@ -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) => { diff --git a/apps/mcp/src/widget/views/Graph.tsx b/apps/mcp/src/widget/views/Graph.tsx index d2933b4a..c584f211 100644 --- a/apps/mcp/src/widget/views/Graph.tsx +++ b/apps/mcp/src/widget/views/Graph.tsx @@ -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, diff --git a/apps/mcp/vite.config.ts b/apps/mcp/vite.config.ts index 70591477..a1acc321 100644 --- a/apps/mcp/vite.config.ts +++ b/apps/mcp/vite.config.ts @@ -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", - }, - }, }, }, })