From 2bbc7fc7a5e7fb7357fe84959a2189ca3d5c2aba Mon Sep 17 00:00:00 2001 From: Prasanna721 <106952318+Prasanna721@users.noreply.github.com> Date: Thu, 5 Mar 2026 16:28:34 +0000 Subject: [PATCH 1/2] feat(mcp): add interactive memory graph MCP App visualization (#763) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### feat(mcp): add memory graph visualization MCP App #### Summary - Add interactive force-directed graph visualization as an MCP App using `force-graph` + `d3-force-3d` - Register `memory-graph` and `fetch-graph-data` tools with MCP Apps SDK UI resources - Add graph API client methods (`getGraphBounds`, `getGraphViewport`) to `SupermemoryClient` - Set up Vite + `vite-plugin-singlefile` build pipeline to produce a self-contained HTML bundle - Support light/dark theme, zoom controls, node popups, and legend #### Files changed - `apps/mcp/src/server.ts` — register app tools + HTML resource - `apps/mcp/src/client.ts` — add graph API types and methods - `apps/mcp/src/ui/mcp-app.ts` — force-graph visualization app - `apps/mcp/src/ui/global.css`, `mcp-app.css` — styling - `apps/mcp/mcp-app.html` — entry HTML for Vite - `apps/mcp/vite.config.ts` — Vite single-file build config - `apps/mcp/package.json` — add UI deps and build scripts - `apps/mcp/wrangler.jsonc` — add HTML text import rule --- apps/mcp/mcp-app.html | 69 +++ apps/mcp/package.json | 10 +- apps/mcp/src/client.ts | 100 +++++ apps/mcp/src/html.d.ts | 4 + apps/mcp/src/server.ts | 171 ++++++++ apps/mcp/src/ui/global.css | 46 ++ apps/mcp/src/ui/mcp-app.css | 194 +++++++++ apps/mcp/src/ui/mcp-app.ts | 580 +++++++++++++++++++++++++ apps/mcp/vite.config.ts | 13 + apps/mcp/wrangler.jsonc | 5 + apps/web/app/(auth)/login/new/page.tsx | 2 +- packages/memory-graph/package.json | 1 + packages/ui/button/external-auth.tsx | 2 +- 13 files changed, 1193 insertions(+), 4 deletions(-) create mode 100644 apps/mcp/mcp-app.html create mode 100644 apps/mcp/src/html.d.ts create mode 100644 apps/mcp/src/ui/global.css create mode 100644 apps/mcp/src/ui/mcp-app.css create mode 100644 apps/mcp/src/ui/mcp-app.ts create mode 100644 apps/mcp/vite.config.ts diff --git a/apps/mcp/mcp-app.html b/apps/mcp/mcp-app.html new file mode 100644 index 000000000..73bad9f1b --- /dev/null +++ b/apps/mcp/mcp-app.html @@ -0,0 +1,69 @@ + + + + + + + Memory Graph + + +
+ +
+
+ Loading memory graph... +
+ +
+ + + +
+ + + +
+ +
+
+ + Memory +
+
+ + Recent (<1d) +
+
+ + Expiring +
+
+ + Forgotten +
+
+
+ Document +
+
+
+ Doc → Memory +
+
+
+ Version +
+
+
+ Similarity +
+
+ + + + diff --git a/apps/mcp/package.json b/apps/mcp/package.json index 5ba472ad9..3b6653190 100644 --- a/apps/mcp/package.json +++ b/apps/mcp/package.json @@ -3,12 +3,14 @@ "version": "4.0.0", "type": "module", "scripts": { - "dev": "wrangler dev", - "deploy": "wrangler deploy --minify", + "build:ui": "vite build", + "dev": "vite build && wrangler dev", + "deploy": "vite build && wrangler deploy --minify", "cf-typegen": "wrangler types --env-interface CloudflareBindings" }, "dependencies": { "@cloudflare/workers-oauth-provider": "^0.2.2", + "@modelcontextprotocol/ext-apps": "^1.0.0", "@modelcontextprotocol/sdk": "^1.25.2", "agents": "^0.3.5", "hono": "^4.11.1", @@ -18,7 +20,11 @@ }, "devDependencies": { "@cloudflare/workers-types": "^4.20250620.0", + "d3-force-3d": "^3.0.5", + "force-graph": "^1.49.0", "typescript": "^5.8.3", + "vite": "^6.0.0", + "vite-plugin-singlefile": "^2.3.0", "wrangler": "^4.4.0" } } diff --git a/apps/mcp/src/client.ts b/apps/mcp/src/client.ts index c5cade72f..3032c6758 100644 --- a/apps/mcp/src/client.ts +++ b/apps/mcp/src/client.ts @@ -37,6 +37,54 @@ export interface Project { documentCount?: number } +// Graph API types +export interface GraphApiMemory { + id: string + memory: string + isStatic: boolean + isLatest: boolean + isForgotten: boolean + forgetAfter: string | null + version: number + parentMemoryId: string | null + createdAt: string + updatedAt: string +} + +export interface GraphApiDocument { + id: string + title: string | null + summary: string | null + documentType: string + createdAt: string + updatedAt: string + x: number + y: number + memories: GraphApiMemory[] +} + +export interface GraphApiEdge { + source: string + target: string + similarity: number +} + +export interface GraphViewportResponse { + documents: GraphApiDocument[] + edges: GraphApiEdge[] + viewport: { minX: number; maxX: number; minY: number; maxY: number } + totalCount: number +} + +export interface GraphBoundsResponse { + bounds: { + minX: number + maxX: number + minY: number + maxY: number + } | null +} + function limitByChars(text: string, maxChars = MAX_CHARS): string { return text.length > maxChars ? `${text.slice(0, maxChars)}...` : text } @@ -215,6 +263,58 @@ export class SupermemoryClient { } } + // Fetch graph bounds for coordinate range + async getGraphBounds(containerTags?: string[]): Promise { + try { + const params = new URLSearchParams() + if (containerTags?.length) { + params.set("containerTags", JSON.stringify(containerTags)) + } + const url = `${this.apiUrl}/v3/graph/bounds${params.toString() ? `?${params}` : ""}` + const response = await fetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${this.bearerToken}`, + "Content-Type": "application/json", + }, + }) + if (!response.ok) { + throw Object.assign(new Error("Failed to fetch graph bounds"), { + status: response.status, + }) + } + return (await response.json()) as GraphBoundsResponse + } catch (error) { + this.handleError(error) + } + } + + // Fetch graph data for a viewport region + async getGraphViewport( + viewport: { minX: number; maxX: number; minY: number; maxY: number }, + containerTags?: string[], + limit = 200, + ): Promise { + try { + const response = await fetch(`${this.apiUrl}/v3/graph/viewport`, { + method: "POST", + headers: { + Authorization: `Bearer ${this.bearerToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ viewport, containerTags, limit }), + }) + if (!response.ok) { + throw Object.assign(new Error("Failed to fetch graph viewport"), { + status: response.status, + }) + } + return (await response.json()) as GraphViewportResponse + } catch (error) { + this.handleError(error) + } + } + private handleError(error: unknown): never { // Handle network/fetch errors if (error instanceof TypeError) { diff --git a/apps/mcp/src/html.d.ts b/apps/mcp/src/html.d.ts new file mode 100644 index 000000000..deb02df28 --- /dev/null +++ b/apps/mcp/src/html.d.ts @@ -0,0 +1,4 @@ +declare module "*.html" { + const content: string + export default content +} diff --git a/apps/mcp/src/server.ts b/apps/mcp/src/server.ts index cc0394662..36ebe61f8 100644 --- a/apps/mcp/src/server.ts +++ b/apps/mcp/src/server.ts @@ -1,8 +1,15 @@ import { McpAgent } from "agents/mcp" import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" +import { + registerAppTool, + registerAppResource, + RESOURCE_MIME_TYPE, +} from "@modelcontextprotocol/ext-apps/server" import { SupermemoryClient } from "./client" import { initPosthog, posthog } from "./posthog" import { z } from "zod" +// @ts-expect-error - wrangler handles HTML imports as text modules via rules config +import mcpAppHtml from "../dist/mcp-app.html" type Env = { MCP_SERVER: DurableObjectNamespace @@ -275,6 +282,170 @@ export class SupermemoryMCP extends McpAgent { }, ) + // Register memory-graph tool with MCP App UI + const memoryGraphResourceUri = "ui://memory-graph/mcp-app.html" + + const memoryGraphSchema = z.object({ + ...(hasRootContainerTag ? {} : containerTagField), + }) + + type MemoryGraphArgs = z.infer + + registerAppTool( + this.server, + "memory-graph", + { + title: "Memory Graph", + description: + "Visualize the user's memory graph as an interactive force-directed graph showing documents, memories, and their relationships.", + inputSchema: memoryGraphSchema, + _meta: { ui: { resourceUri: memoryGraphResourceUri } }, + }, + // @ts-expect-error - zod type inference issue with MCP SDK + async (args: MemoryGraphArgs) => { + try { + const effectiveContainerTag = + (args as { containerTag?: string }).containerTag || + this.props?.containerTag + const client = this.getClient(effectiveContainerTag) + const containerTags = effectiveContainerTag + ? [effectiveContainerTag] + : undefined + + const [bounds, viewport] = await Promise.all([ + client.getGraphBounds(containerTags), + client.getGraphViewport( + { minX: 0, maxX: 1000, minY: 0, maxY: 1000 }, + containerTags, + 200, + ), + ]) + + const memoryCount = viewport.documents.reduce( + (sum, d) => sum + d.memories.length, + 0, + ) + const textParts = [ + `Memory Graph: ${viewport.documents.length} documents, ${memoryCount} memories, ${viewport.edges.length} connections`, + ] + if (effectiveContainerTag) { + textParts.push(`Project: ${effectiveContainerTag}`) + } + + return { + content: [{ type: "text" as const, text: textParts.join(". ") }], + structuredContent: { + containerTag: effectiveContainerTag, + bounds: bounds.bounds, + documents: viewport.documents, + edges: viewport.edges, + totalCount: viewport.totalCount, + }, + } + } catch (error) { + const message = + error instanceof Error + ? error.message + : "An unexpected error occurred" + return { + content: [ + { + type: "text" as const, + text: `Error loading memory graph: ${message}`, + }, + ], + isError: true, + } + } + }, + ) + + // App-only tool for the UI to fetch additional graph data + registerAppTool( + this.server, + "fetch-graph-data", + { + description: "Fetch graph data for a viewport region", + inputSchema: z.object({ + containerTag: z.string().optional(), + viewport: z.object({ + minX: z.number(), + maxX: z.number(), + minY: z.number(), + maxY: z.number(), + }), + limit: z.number().optional().default(200), + }), + _meta: { + ui: { + resourceUri: memoryGraphResourceUri, + visibility: ["app"], + }, + }, + }, + // @ts-expect-error - zod type inference issue with MCP SDK + async (args: { + containerTag?: string + viewport: { + minX: number + maxX: number + minY: number + maxY: number + } + limit?: number + }) => { + try { + const effectiveContainerTag = + args.containerTag || this.props?.containerTag + const client = this.getClient(effectiveContainerTag) + const containerTags = effectiveContainerTag + ? [effectiveContainerTag] + : undefined + const data = await client.getGraphViewport( + args.viewport, + containerTags, + args.limit, + ) + + return { + content: [{ type: "text" as const, text: JSON.stringify(data) }], + structuredContent: data, + } + } catch (error) { + const message = + error instanceof Error + ? error.message + : "An unexpected error occurred" + return { + content: [ + { + type: "text" as const, + text: `Error fetching graph data: ${message}`, + }, + ], + isError: true, + } + } + }, + ) + + // Register HTML resource for the memory graph UI + registerAppResource( + this.server, + "Memory Graph UI", + memoryGraphResourceUri, + { mimeType: RESOURCE_MIME_TYPE }, + async () => ({ + contents: [ + { + uri: memoryGraphResourceUri, + mimeType: RESOURCE_MIME_TYPE, + text: mcpAppHtml as string, + }, + ], + }), + ) + this.server.registerPrompt( "context", { diff --git a/apps/mcp/src/ui/global.css b/apps/mcp/src/ui/global.css new file mode 100644 index 000000000..d7781593c --- /dev/null +++ b/apps/mcp/src/ui/global.css @@ -0,0 +1,46 @@ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, +body { + width: 100%; + height: 600px; + min-height: 600px; + overflow: hidden; + font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + font-size: 14px; +} + +:root { + --bg: #0f1419; + --bg-secondary: #1a1f29; + --text: #e2e8f0; + --text-muted: #94a3b8; + --border: #2a2f36; + --accent: #3b73b8; + --hex-fill: #0d2034; + --doc-fill: #1b1f24; + --doc-stroke: #2a2f36; + --doc-inner: #13161a; +} + +[data-theme="light"] { + --bg: #ffffff; + --bg-secondary: #f8fafc; + --text: #1e293b; + --text-muted: #64748b; + --border: #e2e8f0; + --accent: #2563eb; + --hex-fill: #e8f0fe; + --doc-fill: #f1f5f9; + --doc-stroke: #cbd5e1; + --doc-inner: #e2e8f0; +} + +body { + background: var(--bg); + color: var(--text); +} diff --git a/apps/mcp/src/ui/mcp-app.css b/apps/mcp/src/ui/mcp-app.css new file mode 100644 index 000000000..593df1200 --- /dev/null +++ b/apps/mcp/src/ui/mcp-app.css @@ -0,0 +1,194 @@ +#graph { + width: 100%; + height: 600px; +} + +#loading { + display: flex; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + align-items: center; + gap: 12px; + color: var(--text-muted); + font-size: 14px; + z-index: 10; +} + +#loading .spinner { + width: 20px; + height: 20px; + border: 2px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +#stats { + position: fixed; + top: 12px; + left: 12px; + font-size: 12px; + color: var(--text-muted); + z-index: 10; + padding: 4px 10px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 6px; +} + +#popup { + display: none; + position: fixed; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 10px; + padding: 14px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); + z-index: 100; + min-width: 220px; + max-width: 360px; + max-height: 300px; + overflow-y: auto; +} + +#popup-type { + display: inline-block; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 2px 6px; + border-radius: 4px; + margin-bottom: 6px; +} + +#popup-type.document { + background: rgba(59, 115, 184, 0.15); + color: #3b73b8; +} + +#popup-type.memory { + background: rgba(59, 115, 184, 0.15); + color: #3b73b8; +} + +#popup-type.forgotten { + background: rgba(239, 68, 68, 0.15); + color: #ef4444; +} + +#popup-type.latest { + background: rgba(16, 185, 129, 0.15); + color: #10b981; +} + +#popup-title { + font-weight: 600; + font-size: 14px; + margin-bottom: 6px; + color: var(--text); + word-wrap: break-word; + line-height: 1.4; +} + +#popup-content { + font-size: 12px; + color: var(--text-muted); + margin-bottom: 6px; + word-wrap: break-word; + line-height: 1.5; +} + +#popup-meta { + font-size: 11px; + color: var(--text-muted); + opacity: 0.7; +} + +#controls { + position: fixed; + bottom: 16px; + right: 16px; + display: flex; + flex-direction: row; + gap: 6px; + z-index: 10; +} + +#controls button { + width: 36px; + height: 36px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text); + font-size: 16px; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: + background 0.15s, + border-color 0.15s; +} + +#controls button:hover { + border-color: var(--accent); +} + +#controls button:active { + background: var(--border); +} + +#legend { + position: fixed; + bottom: 16px; + left: 16px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 10px; + padding: 10px 14px; + z-index: 10; + font-size: 11px; + color: var(--text-muted); +} + +.legend-row { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 4px; +} + +.legend-row:last-child { + margin-bottom: 0; +} + +.legend-doc { + width: 10px; + height: 10px; + border-radius: 2px; + background: var(--doc-fill); + border: 1.5px solid var(--doc-stroke); + flex-shrink: 0; +} + +.legend-line { + width: 16px; + height: 0; + border-top: 1.5px solid; + flex-shrink: 0; +} + +.legend-line.dashed { + border-top-style: dashed; +} diff --git a/apps/mcp/src/ui/mcp-app.ts b/apps/mcp/src/ui/mcp-app.ts new file mode 100644 index 000000000..1802cf82c --- /dev/null +++ b/apps/mcp/src/ui/mcp-app.ts @@ -0,0 +1,580 @@ +/** + * Memory Graph MCP App - Interactive force-directed graph visualization + */ +import { + App, + applyDocumentTheme, + applyHostFonts, + applyHostStyleVariables, + type McpUiHostContext, +} from "@modelcontextprotocol/ext-apps" +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js" +import ForceGraph, { type LinkObject, type NodeObject } from "force-graph" +import { + forceCenter, + forceCollide, + forceLink, + forceManyBody, + forceRadial, +} from "d3-force-3d" +import "./global.css" +import "./mcp-app.css" + +// ============================================================================= +// Types +// ============================================================================= +interface GraphApiMemory { + id: string + memory: string + isStatic: boolean + isLatest: boolean + isForgotten: boolean + forgetAfter: string | null + version: number + parentMemoryId: string | null + createdAt: string + updatedAt: string +} + +interface GraphApiDocument { + id: string + title: string | null + summary: string | null + documentType: string + createdAt: string + updatedAt: string + x: number + y: number + memories: GraphApiMemory[] +} + +interface GraphApiEdge { + source: string + target: string + similarity: number +} + +interface ToolResultData { + containerTag?: string + bounds: { minX: number; maxX: number; minY: number; maxY: number } | null + documents: GraphApiDocument[] + edges: GraphApiEdge[] + totalCount: number +} + +interface MemoryNode extends NodeObject { + id: string + nodeType: "memory" + memory: string + documentId: string + isLatest: boolean + isForgotten: boolean + forgetAfter: string | null + version: number + parentMemoryId: string | null + createdAt: string + borderColor: string +} + +interface DocumentNode extends NodeObject { + id: string + nodeType: "document" + title: string + summary: string | null + docType: string + createdAt: string + memoryCount: number +} + +type GraphNode = MemoryNode | DocumentNode + +interface GraphLink extends LinkObject { + source: string | GraphNode + target: string | GraphNode + edgeType: "doc-memory" | "version" | "similarity" + similarity?: number +} + +// ============================================================================= +// Constants +// ============================================================================= +const MEMORY_BORDER = { + forgotten: "#EF4444", + expiring: "#F59E0B", + recent: "#10B981", + default: "#3B73B8", +} + +const EDGE_COLORS = { + dark: { + "doc-memory": "#4A5568", + version: "#8B5CF6", + similarity: "#00D4B8", + }, + light: { + "doc-memory": "#A0AEC0", + version: "#8B5CF6", + similarity: "#0D9488", + }, +} + +const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000 +const ONE_DAY_MS = 24 * 60 * 60 * 1000 +const CLUSTER_SPREAD = 120 + +// ============================================================================= +// State +// ============================================================================= +let isDark = true +let selectedNode: GraphNode | null = null + +// ============================================================================= +// DOM References +// ============================================================================= +const container = document.getElementById("graph")! +const popup = document.getElementById("popup")! +const popupType = document.getElementById("popup-type")! +const popupTitle = document.getElementById("popup-title")! +const popupContent = document.getElementById("popup-content")! +const popupMeta = document.getElementById("popup-meta")! +const loadingEl = document.getElementById("loading")! +const statsEl = document.getElementById("stats")! +const zoomInBtn = document.getElementById("zoom-in")! +const zoomOutBtn = document.getElementById("zoom-out")! +const fitBtn = document.getElementById("fit-btn")! + +// ============================================================================= +// Helpers +// ============================================================================= +function getMemoryBorderColor(mem: GraphApiMemory): string { + if (mem.isForgotten) return MEMORY_BORDER.forgotten + if (mem.forgetAfter) { + const msLeft = new Date(mem.forgetAfter).getTime() - Date.now() + if (msLeft < SEVEN_DAYS_MS) return MEMORY_BORDER.expiring + } + const age = Date.now() - new Date(mem.createdAt).getTime() + if (age < ONE_DAY_MS) return MEMORY_BORDER.recent + return MEMORY_BORDER.default +} + +function normalizeDocCoordinates( + documents: GraphApiDocument[], +): GraphApiDocument[] { + if (documents.length <= 1) return documents + + let minX = Number.POSITIVE_INFINITY + let maxX = Number.NEGATIVE_INFINITY + let minY = Number.POSITIVE_INFINITY + let maxY = Number.NEGATIVE_INFINITY + for (const doc of documents) { + minX = Math.min(minX, doc.x) + maxX = Math.max(maxX, doc.x) + minY = Math.min(minY, doc.y) + maxY = Math.max(maxY, doc.y) + } + + const rangeX = maxX - minX || 1 + const rangeY = maxY - minY || 1 + // Small spread so documents start near each other. + // The force simulation will naturally separate them. + const SPREAD = 50 + + return documents.map((doc) => ({ + ...doc, + x: ((doc.x - minX) / rangeX - 0.5) * SPREAD, + y: ((doc.y - minY) / rangeY - 0.5) * SPREAD, + })) +} + +function transformData(data: ToolResultData): { + nodes: GraphNode[] + links: GraphLink[] +} { + const nodes: GraphNode[] = [] + const links: GraphLink[] = [] + const nodeIds = new Set() + + const normalizedDocs = normalizeDocCoordinates(data.documents) + + for (const doc of normalizedDocs) { + nodes.push({ + id: doc.id, + nodeType: "document", + title: doc.title || "Untitled", + summary: doc.summary, + docType: doc.documentType, + createdAt: doc.createdAt, + memoryCount: doc.memories.length, + x: doc.x, + y: doc.y, + } as DocumentNode) + nodeIds.add(doc.id) + + const memCount = doc.memories.length + for (let i = 0; i < memCount; i++) { + const mem = doc.memories[i]! + const angle = (i / memCount) * 2 * Math.PI + + nodes.push({ + id: mem.id, + nodeType: "memory", + memory: mem.memory, + documentId: doc.id, + isLatest: mem.isLatest, + isForgotten: mem.isForgotten, + forgetAfter: mem.forgetAfter, + version: mem.version, + parentMemoryId: mem.parentMemoryId, + createdAt: mem.createdAt, + borderColor: getMemoryBorderColor(mem), + x: doc.x + Math.cos(angle) * CLUSTER_SPREAD, + y: doc.y + Math.sin(angle) * CLUSTER_SPREAD, + } as MemoryNode) + nodeIds.add(mem.id) + + // Doc-memory link + links.push({ source: doc.id, target: mem.id, edgeType: "doc-memory" }) + + // Version chain link + if (mem.parentMemoryId && nodeIds.has(mem.parentMemoryId)) { + links.push({ + source: mem.parentMemoryId, + target: mem.id, + edgeType: "version", + }) + } + } + } + + // Similarity edges from API + for (const edge of data.edges) { + if (nodeIds.has(edge.source) && nodeIds.has(edge.target)) { + links.push({ + source: edge.source, + target: edge.target, + edgeType: "similarity", + similarity: edge.similarity, + }) + } + } + + return { nodes, links } +} + +// ============================================================================= +// Drawing +// ============================================================================= +function drawHexagon( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + radius: number, + strokeColor: string, +) { + ctx.beginPath() + for (let i = 0; i < 6; i++) { + const angle = (Math.PI / 3) * i - Math.PI / 6 + const px = x + radius * Math.cos(angle) + const py = y + radius * Math.sin(angle) + if (i === 0) ctx.moveTo(px, py) + else ctx.lineTo(px, py) + } + ctx.closePath() + ctx.fillStyle = isDark ? "#0D2034" : "#E8F0FE" + ctx.fill() + ctx.strokeStyle = strokeColor + ctx.lineWidth = 1.5 + ctx.stroke() +} + +function drawDocumentNode( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + size: number, +) { + const half = size / 2 + // Outer rounded rect + ctx.beginPath() + ctx.roundRect(x - half, y - half, size, size, 3) + ctx.fillStyle = isDark ? "#1B1F24" : "#F1F5F9" + ctx.fill() + ctx.strokeStyle = isDark ? "#2A2F36" : "#CBD5E1" + ctx.lineWidth = 1.5 + ctx.stroke() + + // Inner icon area + const iconSize = size * 0.5 + const iconHalf = iconSize / 2 + ctx.beginPath() + ctx.roundRect(x - iconHalf, y - iconHalf, iconSize, iconSize, 2) + ctx.fillStyle = isDark ? "#13161A" : "#E2E8F0" + ctx.fill() +} + +// ============================================================================= +// Force Graph Setup +// ============================================================================= +function getLinkColor(link: GraphLink): string { + const palette = isDark ? EDGE_COLORS.dark : EDGE_COLORS.light + return palette[link.edgeType] || palette["doc-memory"] +} + +const graph = new ForceGraph(container) + .nodeId("id") + .nodeCanvasObject( + (node: GraphNode, ctx: CanvasRenderingContext2D, globalScale: number) => { + const x = node.x! + const y = node.y! + + if (node.nodeType === "memory") { + const mem = node as MemoryNode + drawHexagon(ctx, x, y, 10, mem.borderColor) + + if (globalScale > 2) { + const label = mem.memory.slice(0, 30) + ctx.font = `${Math.max(4, 10 / globalScale)}px system-ui, sans-serif` + ctx.fillStyle = isDark ? "#94a3b8" : "#64748b" + ctx.textAlign = "center" + ctx.textBaseline = "top" + ctx.fillText(label, x, y + 12) + } + } else { + const doc = node as DocumentNode + drawDocumentNode(ctx, x, y, 22) + + if (globalScale > 1.2) { + const label = (doc.title || "").slice(0, 25) + ctx.font = `600 ${Math.max(4, 11 / globalScale)}px system-ui, sans-serif` + ctx.fillStyle = isDark ? "#e2e8f0" : "#1e293b" + ctx.textAlign = "center" + ctx.textBaseline = "top" + ctx.fillText(label, x, y + 14) + } + } + }, + ) + .nodeCanvasObjectMode(() => "replace") + .nodePointerAreaPaint( + (node: GraphNode, color: string, ctx: CanvasRenderingContext2D) => { + ctx.fillStyle = color + ctx.beginPath() + ctx.arc( + node.x!, + node.y!, + node.nodeType === "document" ? 12 : 11, + 0, + Math.PI * 2, + ) + ctx.fill() + }, + ) + .linkWidth((link: GraphLink) => { + if (link.edgeType === "version") return 2 + if (link.edgeType === "similarity") + return 0.5 + (link.similarity || 0) * 1.5 + return 1 + }) + .linkColor(getLinkColor) + .linkLineDash((link: GraphLink) => { + if (link.edgeType === "similarity") return [4, 2] + return null as unknown as number[] + }) + .linkDirectionalArrowLength((link: GraphLink) => + link.edgeType === "version" ? 4 : 0, + ) + .linkDirectionalArrowRelPos(1) + .onNodeClick(handleNodeClick) + .onBackgroundClick(() => hidePopup()) + .d3Force( + "charge", + forceManyBody().strength((node: GraphNode) => + node.nodeType === "document" ? -15 : -200, + ), + ) + .d3Force( + "link", + forceLink() + .distance((l: GraphLink) => (l.edgeType === "doc-memory" ? 40 : 80)) + .strength((l: GraphLink) => { + if (l.edgeType === "doc-memory") return 0.8 + if (l.edgeType === "version") return 1.0 + return (l.similarity || 0.3) * 0.3 + }), + ) + .d3Force("collide", forceCollide(18)) + .d3Force("center", forceCenter()) + .d3Force("bound", forceRadial(60).strength(0.3)) + .d3VelocityDecay(0.4) + .warmupTicks(50) + .cooldownTime(3000) + +// ============================================================================= +// Resize +// ============================================================================= +function handleResize() { + const { width, height } = container.getBoundingClientRect() + graph.width(width).height(height) +} +window.addEventListener("resize", handleResize) +handleResize() + +// ============================================================================= +// Popup +// ============================================================================= +function handleNodeClick(node: GraphNode, event: MouseEvent) { + if (selectedNode?.id === node.id) { + hidePopup() + return + } + selectedNode = node + showPopup(node, event.clientX, event.clientY) +} + +function showPopup(node: GraphNode, x: number, y: number) { + if (node.nodeType === "document") { + const doc = node as DocumentNode + popupType.textContent = "Document" + popupType.className = "document" + popupTitle.textContent = doc.title + popupContent.textContent = doc.summary || "No summary available" + popupMeta.textContent = `${doc.memoryCount} memories \u00b7 ${doc.docType} \u00b7 ${new Date(doc.createdAt).toLocaleDateString()}` + } else { + const mem = node as MemoryNode + const typeLabel = mem.isForgotten + ? "Forgotten" + : mem.isLatest + ? "Latest" + : `v${mem.version}` + popupType.textContent = typeLabel + popupType.className = `memory${mem.isForgotten ? " forgotten" : mem.isLatest ? " latest" : ""}` + popupTitle.textContent = + mem.memory.length > 120 ? `${mem.memory.slice(0, 120)}...` : mem.memory + popupContent.textContent = mem.memory.length > 120 ? mem.memory : "" + + const statusParts: string[] = [`Version ${mem.version}`] + if (mem.isForgotten) statusParts.push("Forgotten") + else if (mem.forgetAfter) + statusParts.push( + `Expires ${new Date(mem.forgetAfter).toLocaleDateString()}`, + ) + statusParts.push(new Date(mem.createdAt).toLocaleDateString()) + popupMeta.textContent = statusParts.join(" \u00b7 ") + } + + popup.style.display = "block" + + const rect = popup.getBoundingClientRect() + const gap = 15 + const left = x < window.innerWidth / 2 ? x + gap : x - rect.width - gap + const top = y < window.innerHeight / 2 ? y + gap : y - rect.height - gap + popup.style.left = `${Math.max(8, left)}px` + popup.style.top = `${Math.max(8, top)}px` +} + +function hidePopup() { + popup.style.display = "none" + selectedNode = null +} + +// ============================================================================= +// Controls +// ============================================================================= +const ZOOM_FACTOR = 1.5 +zoomInBtn.addEventListener("click", () => + graph.zoom(graph.zoom() * ZOOM_FACTOR, 200), +) +zoomOutBtn.addEventListener("click", () => + graph.zoom(graph.zoom() / ZOOM_FACTOR, 200), +) +fitBtn.addEventListener("click", () => graph.zoomToFit(400, 40)) + +document.addEventListener("keydown", (e) => { + if (e.key === "Escape") hidePopup() +}) + +// ============================================================================= +// Theme +// ============================================================================= +function applyTheme(theme: "light" | "dark") { + isDark = theme === "dark" + document.documentElement.setAttribute("data-theme", theme) + graph.backgroundColor(isDark ? "#0f1419" : "#ffffff") +} + +// Detect system theme +const prefersDark = window.matchMedia("(prefers-color-scheme: dark)") +applyTheme(prefersDark.matches ? "dark" : "light") +prefersDark.addEventListener("change", (e) => + applyTheme(e.matches ? "dark" : "light"), +) + +// ============================================================================= +// MCP App SDK +// ============================================================================= +const app = new App({ name: "Memory Graph", version: "1.0.0" }) + +app.ontoolinput = () => { + loadingEl.style.display = "flex" + statsEl.textContent = "Loading graph data..." +} + +app.ontoolresult = (result: CallToolResult) => { + loadingEl.style.display = "none" + + if (result.isError) { + statsEl.textContent = "Error loading graph" + return + } + + const data = result.structuredContent as unknown as ToolResultData + if (!data?.documents) { + statsEl.textContent = "No graph data available" + return + } + + const { nodes, links } = transformData(data) + const memCount = nodes.filter((n) => n.nodeType === "memory").length + const docCount = nodes.filter((n) => n.nodeType === "document").length + + statsEl.textContent = `${docCount} docs \u00b7 ${memCount} memories \u00b7 ${links.length} connections` + + graph.graphData({ nodes, links }) + + // Fit to view after layout stabilizes + setTimeout(() => graph.zoomToFit(400, 40), 600) +} + +app.ontoolcancelled = () => { + loadingEl.style.display = "none" + statsEl.textContent = "Cancelled" +} + +function handleHostContext(ctx: McpUiHostContext) { + if (ctx.theme) { + applyDocumentTheme(ctx.theme) + applyTheme(ctx.theme) + } + if (ctx.styles?.variables) { + applyHostStyleVariables(ctx.styles.variables) + } + if (ctx.styles?.css?.fonts) { + applyHostFonts(ctx.styles.css.fonts) + } + if (ctx.safeAreaInsets) { + const { top, right, bottom, left } = ctx.safeAreaInsets + document.body.style.padding = `${top}px ${right}px ${bottom}px ${left}px` + } +} + +app.onhostcontextchanged = handleHostContext + +app.onteardown = async () => ({}) + +app.onerror = console.error + +// Connect to host +app.connect().then(() => { + const ctx = app.getHostContext() + if (ctx) handleHostContext(ctx) +}) diff --git a/apps/mcp/vite.config.ts b/apps/mcp/vite.config.ts new file mode 100644 index 000000000..aa4e06338 --- /dev/null +++ b/apps/mcp/vite.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "vite" +import { viteSingleFile } from "vite-plugin-singlefile" + +export default defineConfig({ + plugins: [viteSingleFile()], + build: { + outDir: "dist", + emptyOutDir: false, + rollupOptions: { + input: "mcp-app.html", + }, + }, +}) diff --git a/apps/mcp/wrangler.jsonc b/apps/mcp/wrangler.jsonc index 2260493a4..1c2f3a193 100644 --- a/apps/mcp/wrangler.jsonc +++ b/apps/mcp/wrangler.jsonc @@ -1,10 +1,15 @@ { "$schema": "node_modules/wrangler/config-schema.json", "name": "supermemory-mcp", + "build": { + "command": "bun run build:ui" + }, "main": "src/index.ts", "compatibility_date": "2025-01-01", "compatibility_flags": ["nodejs_compat"], + "rules": [{ "type": "Text", "globs": ["**/*.html"], "fallthrough": false }], + "vars": { "API_URL": "https://api.supermemory.ai" }, diff --git a/apps/web/app/(auth)/login/new/page.tsx b/apps/web/app/(auth)/login/new/page.tsx index 59a0cbdbb..694f1b688 100644 --- a/apps/web/app/(auth)/login/new/page.tsx +++ b/apps/web/app/(auth)/login/new/page.tsx @@ -415,7 +415,7 @@ export default function LoginPage() { "aria-invalid": error ? "true" : "false", disabled: isLoading, id: "email", - onChange: (e) => { + onChange: (e: React.ChangeEvent) => { setEmail(e.target.value) error && setError(null) }, diff --git a/packages/memory-graph/package.json b/packages/memory-graph/package.json index 201ff32e3..a0cb5bb48 100644 --- a/packages/memory-graph/package.json +++ b/packages/memory-graph/package.json @@ -70,6 +70,7 @@ "motion": "^12.23.24" }, "devDependencies": { + "@types/d3-force": "^3.0.10", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", "@vanilla-extract/vite-plugin": "^5.1.1", diff --git a/packages/ui/button/external-auth.tsx b/packages/ui/button/external-auth.tsx index 314cb4fb4..423ff5345 100644 --- a/packages/ui/button/external-auth.tsx +++ b/packages/ui/button/external-auth.tsx @@ -1,7 +1,7 @@ import { cn } from "@lib/utils"; import { Button } from "@ui/components/button"; -interface ExternalAuthButtonProps extends React.ComponentProps { +export interface ExternalAuthButtonProps extends React.ComponentProps<"button"> { authProvider: string; authIcon: React.ReactNode; } From bf92ad93a7cb31e52b18fee100ed6333664eeb99 Mon Sep 17 00:00:00 2001 From: Dhravya Shah Date: Thu, 5 Mar 2026 08:29:26 -0800 Subject: [PATCH 2/2] proper tracking --- packages/lib/posthog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/lib/posthog.tsx b/packages/lib/posthog.tsx index 470758046..540f2ab20 100644 --- a/packages/lib/posthog.tsx +++ b/packages/lib/posthog.tsx @@ -47,8 +47,8 @@ export function PostHogProvider({ children }: { children: React.ReactNode }) { person_profiles: "identified_only", capture_pageview: false, capture_pageleave: true, + loaded: (ph) => ph.register({ app: "app" }), }) - posthog.register({ app: "app" }) } }, [])