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...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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/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" })
}
}, [])
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;
}