feat(mcp): add interactive memory graph MCP App visualization (#763)

### 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
This commit is contained in:
Prasanna721 2026-03-05 16:28:34 +00:00
parent 5e5d446659
commit 2bbc7fc7a5
13 changed files with 1193 additions and 4 deletions

69
apps/mcp/mcp-app.html Normal file
View file

@ -0,0 +1,69 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light dark">
<title>Memory Graph</title>
</head>
<body>
<div id="graph"></div>
<div id="loading">
<div class="spinner"></div>
<span>Loading memory graph...</span>
</div>
<div id="stats"></div>
<div id="popup">
<span id="popup-type"></span>
<div id="popup-title"></div>
<div id="popup-content"></div>
<div id="popup-meta"></div>
</div>
<div id="controls">
<button id="fit-btn" title="Fit to view">&#x229E;</button>
<button id="zoom-in" title="Zoom in">+</button>
<button id="zoom-out" title="Zoom out">&minus;</button>
</div>
<div id="legend">
<div class="legend-row">
<svg width="12" height="12" viewBox="0 0 12 12"><polygon points="6,1.5 10.4,3.75 10.4,8.25 6,10.5 1.6,8.25 1.6,3.75" fill="#0D2034" stroke="#3B73B8" stroke-width="1.2"/></svg>
<span>Memory</span>
</div>
<div class="legend-row">
<svg width="12" height="12" viewBox="0 0 12 12"><polygon points="6,1.5 10.4,3.75 10.4,8.25 6,10.5 1.6,8.25 1.6,3.75" fill="#0D2034" stroke="#10B981" stroke-width="1.2"/></svg>
<span>Recent (&lt;1d)</span>
</div>
<div class="legend-row">
<svg width="12" height="12" viewBox="0 0 12 12"><polygon points="6,1.5 10.4,3.75 10.4,8.25 6,10.5 1.6,8.25 1.6,3.75" fill="#0D2034" stroke="#F59E0B" stroke-width="1.2"/></svg>
<span>Expiring</span>
</div>
<div class="legend-row">
<svg width="12" height="12" viewBox="0 0 12 12"><polygon points="6,1.5 10.4,3.75 10.4,8.25 6,10.5 1.6,8.25 1.6,3.75" fill="#0D2034" stroke="#EF4444" stroke-width="1.2"/></svg>
<span>Forgotten</span>
</div>
<div class="legend-row">
<div class="legend-doc"></div>
<span>Document</span>
</div>
<div class="legend-row">
<div class="legend-line" style="border-color: #4A5568;"></div>
<span>Doc &rarr; Memory</span>
</div>
<div class="legend-row">
<div class="legend-line" style="border-color: #8B5CF6; border-width: 2px;"></div>
<span>Version</span>
</div>
<div class="legend-row">
<div class="legend-line dashed" style="border-color: #00D4B8;"></div>
<span>Similarity</span>
</div>
</div>
<script type="module" src="/src/ui/mcp-app.ts"></script>
</body>
</html>

View file

@ -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"
}
}

View file

@ -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<GraphBoundsResponse> {
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<GraphViewportResponse> {
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) {

4
apps/mcp/src/html.d.ts vendored Normal file
View file

@ -0,0 +1,4 @@
declare module "*.html" {
const content: string
export default content
}

View file

@ -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<Env, unknown, Props> {
},
)
// 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<typeof memoryGraphSchema>
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",
{

View file

@ -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);
}

194
apps/mcp/src/ui/mcp-app.css Normal file
View file

@ -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;
}

580
apps/mcp/src/ui/mcp-app.ts Normal file
View file

@ -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<string>()
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<GraphNode, GraphLink>(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)
})

13
apps/mcp/vite.config.ts Normal file
View file

@ -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",
},
},
})

View file

@ -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"
},

View file

@ -415,7 +415,7 @@ export default function LoginPage() {
"aria-invalid": error ? "true" : "false",
disabled: isLoading,
id: "email",
onChange: (e) => {
onChange: (e: React.ChangeEvent<HTMLInputElement>) => {
setEmail(e.target.value)
error && setError(null)
},

View file

@ -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",

View file

@ -1,7 +1,7 @@
import { cn } from "@lib/utils";
import { Button } from "@ui/components/button";
interface ExternalAuthButtonProps extends React.ComponentProps<typeof Button> {
export interface ExternalAuthButtonProps extends React.ComponentProps<"button"> {
authProvider: string;
authIcon: React.ReactNode;
}