mcp: relocate enterprise-mcp as the public mcp revamp (apps/mcp)

Replaces the old apps/mcp with the enterprise-mcp code (functionality unchanged). package.json: worker supermemory-mcp, catalog deps pinned to explicit versions, portless dev:app. wrangler: name supermemory-mcp, route mcp.supermemory.ai, DO EnterpriseMCP. Builds + boots + OAuth discovery/401/worker-to-api verified locally.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Prasanna A P 2026-06-15 15:43:22 -07:00
parent 313a8df6a6
commit a95b9e9052
86 changed files with 4929 additions and 2916 deletions

View file

@ -1,95 +0,0 @@
<!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 type="button" id="fit-btn" title="Fit to view">
<span>Fit</span>
<kbd>Z</kbd>
</button>
<button type="button" id="center-btn" title="Center graph">
<span>Center</span>
<kbd>C</kbd>
</button>
<div id="zoom-row">
<span id="zoom-display">100%</span>
<button type="button" id="zoom-out" title="Zoom out">&minus;</button>
<button type="button" id="zoom-in" title="Zoom in">+</button>
</div>
</div>
<div id="legend" class="collapsed">
<div id="legend-toggle">
<span>Legend</span>
<svg class="legend-chevron" width="10" height="10" viewBox="0 0 10 10" aria-hidden="true"><path d="M3 2l4 3-4 3" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>
</div>
<div id="legend-body">
<div class="legend-group">
<div class="legend-row">
<svg width="12" height="12" viewBox="0 0 14 14" aria-hidden="true"><rect x="1" y="1" width="12" height="12" rx="3" fill="var(--doc-fill)" stroke="var(--doc-stroke)" stroke-width="1.2"/></svg>
<span>Documents</span>
<span class="legend-count" id="legend-doc-count"></span>
</div>
<div class="legend-row">
<svg width="12" height="12" viewBox="0 0 14 14" aria-hidden="true"><polygon points="7,1.5 12,4 12,9.5 7,12 2,9.5 2,4" fill="var(--hex-fill)" stroke="#3B73B8" stroke-width="1.2"/></svg>
<span>Memories</span>
<span class="legend-count" id="legend-mem-count"></span>
</div>
</div>
<div class="legend-divider"></div>
<div class="legend-group">
<div class="legend-row">
<svg width="12" height="12" viewBox="0 0 14 14" aria-hidden="true"><polygon points="7,1.5 12,4 12,9.5 7,12 2,9.5 2,4" fill="var(--hex-fill)" stroke="#10B981" stroke-width="1.5"/></svg>
<span>Recent</span>
</div>
<div class="legend-row">
<svg width="12" height="12" viewBox="0 0 14 14" aria-hidden="true"><polygon points="7,1.5 12,4 12,9.5 7,12 2,9.5 2,4" fill="var(--hex-fill)" stroke="#F59E0B" stroke-width="1.5"/></svg>
<span>Expiring</span>
</div>
<div class="legend-row">
<svg width="12" height="12" viewBox="0 0 14 14" aria-hidden="true"><polygon points="7,1.5 12,4 12,9.5 7,12 2,9.5 2,4" fill="var(--hex-fill)" stroke="#EF4444" stroke-width="1.5"/></svg>
<span>Forgotten</span>
</div>
</div>
<div class="legend-divider"></div>
<div class="legend-group">
<div class="legend-row">
<div class="legend-line" style="border-color: #FBBF24;"></div>
<span>Derives</span>
</div>
<div class="legend-row">
<div class="legend-line" style="border-color: #A78BFA; border-width: 2px;"></div>
<span>Updates</span>
</div>
<div class="legend-row">
<div class="legend-line dashed" style="border-color: #38BDF8;"></div>
<span>Extends</span>
</div>
</div>
</div>
</div>
<script type="module" src="/src/ui/mcp-app.ts"></script>
</body>
</html>

View file

@ -1,34 +1,47 @@
{
"name": "supermemory-mcp",
"version": "4.0.0",
"type": "module",
"portless": { "name": "mcp.dev.supermemory", "script": "dev:app" },
"scripts": {
"build:ui": "vite build",
"dev": "portless",
"dev:app": "vite build && wrangler dev --port ${PORT:-8788}",
"deploy": "vite build && wrangler deploy --minify",
"cf-typegen": "wrangler types --env-interface CloudflareBindings",
"test:e2e": "vitest run"
},
"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",
"posthog-node": "^5.18.0",
"supermemory": "^4.0.0",
"zod": "^3.25.76"
},
"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",
"vitest": "^3.2.4",
"wrangler": "^4.4.0"
}
"name": "supermemory-mcp",
"version": "1.0.0",
"type": "module",
"scripts": {
"build:widget": "vite build",
"build": "vite build",
"dev": "portless",
"dev:app": "vite build && wrangler dev --port ${PORT:-8788}",
"dev:widget": "vite --config vite.config.dev.ts",
"studio": "vite --config vite.config.dev.ts --open /studio.html",
"deploy": "vite build && wrangler deploy --minify",
"check-types": "tsc --noEmit -p tsconfig.widget.json",
"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",
"@phosphor-icons/react": "^2.1.10",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tooltip": "^1.2.7",
"@supermemory/memory-graph": "^0.2.0",
"agents": "^0.3.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"hono": "^4.11.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"supermemory": "^4.0.0",
"tailwind-merge": "^3.4.0",
"zod": "^3.25.76"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250620.0",
"@tailwindcss/vite": "^4.1.13",
"@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.1.13",
"typescript": "^5.8.3",
"vite": "^6.0.0",
"vite-plugin-singlefile": "^2.3.0",
"wrangler": "^4.4.0"
}
}

View file

@ -1,161 +0,0 @@
/**
* Authentication via API introspection
*
* This validates OAuth tokens and API keys by calling the main Supermemory API,
*/
export interface AuthUser {
userId: string
apiKey: string
email?: string
name?: string
}
/**
* Check if a token is an API key (starts with "sm_")
*/
export function isApiKey(token: string): boolean {
return token.startsWith("sm_")
}
/**
* Validate API key by calling the main API's session endpoint.
* Returns user info if the API key is valid.
*/
export async function validateApiKey(
apiKey: string,
apiUrl: string,
): Promise<AuthUser | null> {
try {
const sessionResponse = await fetch(`${apiUrl}/v3/session`, {
method: "GET",
headers: {
Authorization: `Bearer ${apiKey}`,
},
})
if (!sessionResponse.ok) {
const responseText = await sessionResponse.text()
const status = sessionResponse.status
if (status === 401) {
console.error("API key validation failed: Invalid or expired API key")
} else if (status === 403) {
console.error(
"API key validation failed: User is blocked or access forbidden",
responseText,
)
} else if (status === 429) {
console.error("API key validation failed: Rate limit exceeded")
} else if (status >= 500) {
console.error(
"API key validation failed: Server error",
status,
responseText,
)
} else {
console.error("API key validation failed:", status, responseText)
}
return null
}
const sessionData = (await sessionResponse.json()) as {
user?: {
id?: string
email?: string
name?: string
}
session?: unknown
org?: unknown
error?: string
} | null
if (!sessionData?.user?.id) {
console.error("Missing user.id in session response:", sessionData)
return null
}
console.log("API key validated for user:", sessionData.user.id)
return {
userId: sessionData.user.id,
apiKey: apiKey,
email: sessionData.user.email,
name: sessionData.user.name,
}
} catch (error) {
console.error("API key validation error:", error)
return null
}
}
/**
* Validate OAuth token by calling the main API's MCP session endpoint.
* The main API validates the token via better-auth and returns user info + API key.
*/
export async function validateOAuthToken(
token: string,
apiUrl: string,
): Promise<AuthUser | null> {
try {
const sessionResponse = await fetch(`${apiUrl}/v3/mcp/session-with-key`, {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
},
})
if (!sessionResponse.ok) {
const responseText = await sessionResponse.text()
const status = sessionResponse.status
if (status === 401) {
console.error("Token validation failed: Invalid or expired token")
} else if (status === 403) {
console.error(
"Token validation failed: User is blocked or access forbidden",
responseText,
)
} else if (status === 429) {
console.error("Token validation failed: Rate limit exceeded")
} else if (status >= 500) {
console.error(
"Token validation failed: Server error",
status,
responseText,
)
} else {
console.error("Token validation failed:", status, responseText)
}
return null
}
const sessionData = (await sessionResponse.json()) as {
userId?: string
apiKey?: string
email?: string
name?: string
error?: string
} | null
if (!sessionData?.userId || !sessionData?.apiKey) {
console.error(
"Missing userId or apiKey in session response:",
sessionData,
)
return null
}
console.log("OAuth validated, got API key for user:", sessionData.userId)
return {
userId: sessionData.userId,
apiKey: sessionData.apiKey,
email: sessionData.email,
name: sessionData.name,
}
} catch (error) {
console.error("Token validation error:", error)
return null
}
}

View file

@ -1,157 +0,0 @@
export function formatMemories(
response: { results?: Array<Record<string, unknown>>; total?: number },
opts: {
minSimilarity?: number
maxRelations?: number
maxDocuments?: number
maxChunkLength?: number
includeScores?: boolean
includeLegend?: boolean
} = {},
) {
const {
minSimilarity = 0,
maxRelations = 4,
maxDocuments = 3,
maxChunkLength = Number.POSITIVE_INFINITY,
includeScores = true,
includeLegend = true,
} = opts
const day = (s: string | null | undefined) => s?.slice(0, 10) ?? ""
const mime = (m: string | undefined) =>
!m
? ""
: m === "application/pdf"
? "pdf"
: m.includes("spreadsheet")
? "xlsx"
: m.includes("presentation")
? "pptx"
: m.includes("document")
? "doc"
: (m.split("/").pop() ?? "")
const temporal = (tc: Record<string, unknown> | undefined) => {
if (!tc) return [] as string[]
const ev = ((tc.eventDate as string[]) ?? []).map(day).filter(Boolean)
return [
tc.documentDate && `doc ${day(tc.documentDate as string)}`,
ev.length === 1 && `event ${ev[0]}`,
ev.length > 1 && `event ${ev[0]}${ev.at(-1)}`,
].filter(Boolean) as string[]
}
const describeMeta = (m: Record<string, unknown> | undefined | null) => {
if (!m) return ""
const tags = [
mime(m.mimeType as string | undefined),
m.source as string | undefined,
...temporal(m.temporalContext as Record<string, unknown> | undefined),
].filter(Boolean)
return [m.title && `"${m.title}"`, tags.length && `(${tags.join(", ")})`]
.filter(Boolean)
.join(" ")
}
const renderRelations = (
rels: Array<Record<string, unknown>> | undefined,
arrow: string,
root: string,
) => {
if (!rels?.length) return [] as string[]
const seen = new Set<string>()
const items = rels.filter((r) => {
const k = (r.memory as string).trim()
if (k === root.trim() || seen.has(k)) return false
seen.add(k)
return true
})
const shown = items.slice(0, maxRelations)
const lines = shown.map((r) => {
const t = temporal(
(r.metadata as Record<string, unknown> | undefined)?.temporalContext as
| Record<string, unknown>
| undefined,
)
const when = t.length ? t.join(", ") : day(r.updatedAt as string)
return ` ${arrow} ${r.relation}${when ? `, ${when}` : ""}: ${r.memory}`
})
if (items.length > shown.length)
lines.push(` ${arrow} … +${items.length - shown.length} more`)
return lines
}
const renderDocs = (ds: Array<Record<string, unknown>> | undefined) =>
(ds ?? []).slice(0, maxDocuments).map((d) => {
const title = d.title ? `"${d.title}"` : "(untitled)"
const type = d.type ? ` (${d.type})` : ""
const summary = d.summary ? `${d.summary}` : ""
return ` Document: ${title}${type}${summary}`
})
const results = (response.results ?? []).filter(
(m) => ((m.similarity as number) ?? 0) >= minSimilarity,
)
if (!results.length) return "No relevant memories found."
const total = response.total ?? results.length
const header = [
`${results.length} memor${results.length === 1 ? "y" : "ies"}` +
(total !== results.length ? ` of ${total}` : "") +
", ranked by relevance.",
includeLegend &&
"Markers: 'agg' = aggregated synthesis, 'chunk' = raw excerpt; ← parent, → child, ~ related.",
]
.filter(Boolean)
.join(" ")
const arrows = [
["parents", "←"],
["children", "→"],
["related", "~"],
] as const
const blocks = results.map((m) => {
const score = (m.similarity as number)?.toFixed(2) ?? "—"
const prefix = includeScores ? `${score} ` : ""
const memory = (m.memory as string) ?? ""
if (m.isAggregated) return `${prefix}agg ${memory}`
if (m.chunk != null && m.memory == null) {
const body = (m.chunk as string).replace(/\s+$/, "")
const text =
body.length > maxChunkLength
? `${body.slice(0, maxChunkLength)} … [truncated, ${body.length - maxChunkLength} more chars]`
: body
return [
`${prefix}chunk ${describeMeta(m.metadata as Record<string, unknown> | null)}`.trimEnd(),
...renderDocs(
m.documents as Array<Record<string, unknown>> | undefined,
),
...text.split("\n").map((l: string) => ` ${l}`),
].join("\n")
}
const meta = describeMeta(m.metadata as Record<string, unknown> | null)
const ctx = (m.context ?? {}) as Record<
string,
Array<Record<string, unknown>>
>
return [
`${prefix}${memory}`,
meta
? ` Source: ${meta}`
: day(m.updatedAt as string)
? ` Source: updated ${day(m.updatedAt as string)}`
: null,
...renderDocs(m.documents as Array<Record<string, unknown>> | undefined),
...arrows.flatMap(([k, a]) => renderRelations(ctx[k], a, memory)),
]
.filter(Boolean)
.join("\n")
})
return [header, "", blocks.join("\n\n")].join("\n")
}

View file

@ -1,135 +0,0 @@
import { PostHog } from "posthog-node"
const MCP_SERVER_VERSION = "4.0.0"
/**
* PostHog singleton for analytics.
*/
let instance: PostHog | null = null
let initialized = false
/**
* Initialize PostHog with the provided API key.
*/
export function initPosthog(apiKey?: string): void {
if (initialized) return
initialized = true
if (!apiKey) {
return
}
instance = new PostHog(apiKey, {
host: "https://us.i.posthog.com",
})
}
function getInstance(): PostHog | null {
if (!initialized) {
console.warn(
"PostHog not initialized. Call initPosthog(apiKey) during worker startup.",
)
}
return instance
}
export async function memoryAdded(props: {
type: "note" | "link" | "file"
project_id?: string
content_length?: number
file_size?: number
file_type?: string
source?: string
userId: string
mcp_client_name?: string
mcp_client_version?: string
sessionId?: string
containerTag?: string
}): Promise<void> {
const client = getInstance()
if (!client) return
try {
client.capture({
distinctId: props.userId,
event: "memory_added",
properties: {
...props,
mcp_server_version: MCP_SERVER_VERSION,
},
})
} catch (error) {
console.error("PostHog tracking error:", error)
}
}
export async function memorySearch(props: {
query_length: number
results_count: number
search_duration_ms: number
container_tags_count?: number
source?: string
userId: string
mcp_client_name?: string
mcp_client_version?: string
sessionId?: string
containerTag?: string
}): Promise<void> {
const client = getInstance()
if (!client) return
try {
client.capture({
distinctId: props.userId,
event: "memory_search",
properties: {
...props,
mcp_server_version: MCP_SERVER_VERSION,
},
})
} catch (error) {
console.error("PostHog tracking error:", error)
}
}
export async function memoryForgot(props: {
userId: string
content_length?: number
source?: string
mcp_client_name?: string
mcp_client_version?: string
sessionId?: string
containerTag?: string
}): Promise<void> {
const client = getInstance()
if (!client) return
try {
client.capture({
distinctId: props.userId,
event: "memory_forgot",
properties: {
...props,
mcp_server_version: MCP_SERVER_VERSION,
},
})
} catch (error) {
console.error("PostHog tracking error:", error)
}
}
export async function shutdown(): Promise<void> {
if (instance) {
await instance.shutdown()
instance = null
initialized = false
}
}
export const posthog = {
init: initPosthog,
memoryAdded,
memorySearch,
memoryForgot,
shutdown,
}

View file

@ -1,778 +0,0 @@
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 { formatMemories } from "./format"
import { initPosthog, posthog } from "./posthog"
import { z } from "zod"
import mcpAppHtml from "../dist/mcp-app.html"
type Env = {
MCP_SERVER: DurableObjectNamespace
API_URL?: string
POSTHOG_API_KEY?: string
}
type Props = {
userId: string
apiKey: string
containerTag?: string
email?: string
name?: string
}
const CONTAINER_TAGS_TTL_MS = 5 * 60 * 1000
const MAX_RECALL_CHARS = 200000
export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
private clientInfo: { name: string; version?: string } | null = null
private cachedContainerTags: string[] = []
private containerTagsLastFetchedAt: number | null = null
server = new McpServer({
name: "supermemory",
version: "4.0.0",
})
async init() {
const storedClientInfo = await this.ctx.storage.get<{
name: string
version?: string
}>("clientInfo")
if (storedClientInfo) {
this.clientInfo = storedClientInfo
}
initPosthog(this.env.POSTHOG_API_KEY)
// Hook MCP McpAgent to capture client info
this.server.server.oninitialized = async () => {
const clientVersion = this.server.server.getClientVersion()
if (clientVersion) {
this.clientInfo = {
name: clientVersion.name,
version: clientVersion.version,
}
await this.ctx.storage.put("clientInfo", this.clientInfo)
}
}
await this.refreshContainerTags()
const hasRootContainerTag = !!this.props?.containerTag
const containerTagField = {
containerTag: z
.string()
.max(128, "Container tag exceeds maximum length")
.describe(this.getContainerTagDescription())
.optional(),
}
const memorySchema = z.object({
content: z
.string()
.max(200000, "Content exceeds maximum length of 200,000 characters")
.describe("The memory content to save or forget"),
action: z.enum(["save", "forget"]).optional().default("save"),
...(hasRootContainerTag ? {} : containerTagField),
})
const recallSchema = z.object({
query: z
.string()
.max(1000, "Query exceeds maximum length of 1,000 characters")
.describe("The search query to find relevant memories"),
includeProfile: z.boolean().optional().default(true),
...(hasRootContainerTag ? {} : containerTagField),
})
const contextPromptSchema = z.object({
includeRecent: z
.boolean()
.optional()
.default(true)
.describe("Include recent activity in the profile"),
...(hasRootContainerTag ? {} : containerTagField),
})
type ContextPromptArgs = z.infer<typeof contextPromptSchema>
type MemoryArgs = z.infer<typeof memorySchema>
type RecallArgs = z.infer<typeof recallSchema>
// Register memory tool
this.server.registerTool(
"memory",
{
description:
"DO NOT USE ANY OTHER MEMORY TOOL ONLY USE THIS ONE. Save or forget information about the user. Use 'save' when user shares preferences, facts, or asks to remember something. Use 'forget' when information is outdated or user requests removal.",
inputSchema: memorySchema,
},
// @ts-expect-error - zod type inference issue with MCP SDK
(args: MemoryArgs) => this.handleMemory(args),
)
// Register recall tool
this.server.registerTool(
"recall",
{
description:
"DO NOT USE ANY OTHER RECALL TOOL ONLY USE THIS ONE. Search the user's memories. Returns relevant memories plus their profile summary.",
inputSchema: recallSchema,
},
// @ts-expect-error - zod type inference issue with MCP SDK
(args: RecallArgs) => this.handleRecall(args),
)
// Register profile resource
this.server.registerResource(
"User Profile",
"supermemory://profile",
{},
async () => {
const client = this.getClient()
const profileResult = await client.getProfile()
const parts: string[] = ["# User Profile\n"]
if (profileResult.profile.static.length > 0) {
parts.push("## Stable Preferences")
for (const fact of profileResult.profile.static) {
parts.push(`- ${fact}`)
}
}
if (profileResult.profile.dynamic.length > 0) {
parts.push("\n## Recent Activity")
for (const fact of profileResult.profile.dynamic) {
parts.push(`- ${fact}`)
}
}
return {
contents: [
{
uri: "supermemory://profile",
mimeType: "text/plain",
text:
parts.length > 1
? parts.join("\n")
: "No profile yet. Start saving memories.",
},
],
}
},
)
// Register projects resource
this.server.registerResource(
"My Projects",
"supermemory://projects",
{},
async () => {
await this.ensureContainerTagsFresh()
const projects = this.cachedContainerTags
return {
contents: [
{
uri: "supermemory://projects",
mimeType: "application/json",
text: JSON.stringify({ projects }, null, 2),
},
],
}
},
)
// Register listProjects tool
this.server.registerTool(
"listProjects",
{
description:
"List all available projects for organizing memories. Use this to discover valid project names for memory/recall operations.",
inputSchema: z.object({
refresh: z
.boolean()
.optional()
.default(false)
.describe(
"Force refresh from the server (default: false; uses cache with TTL)",
),
}),
},
// @ts-expect-error - zod type inference issue with MCP SDK
async (args: { refresh?: boolean }) => {
try {
if (args.refresh === true) {
await this.refreshContainerTags()
} else {
await this.ensureContainerTagsFresh()
}
const projects = this.cachedContainerTags
if (projects.length === 0) {
return {
content: [
{
type: "text" as const,
text: "No projects found. Memories will use the default project.",
},
],
}
}
return {
content: [
{
type: "text" as const,
text: `Available projects:\n${projects.map((p) => `- ${p}`).join("\n")}`,
},
],
}
} catch (error) {
const message =
error instanceof Error
? error.message
: "An unexpected error occurred"
return {
content: [
{
type: "text" as const,
text: `Error listing projects: ${message}`,
},
],
isError: true,
}
}
},
)
// Register whoAmI tool
this.server.registerTool(
"whoAmI",
{
description: "Get the current logged-in user's information",
inputSchema: z.object({}),
},
// @ts-expect-error - zod type inference issue with MCP SDK
async () => {
if (!this.props) {
return {
content: [
{
type: "text" as const,
text: "User not authenticated",
},
],
}
}
const clientInfo = await this.getClientInfo()
return {
content: [
{
type: "text" as const,
text: JSON.stringify({
userId: this.props.userId,
email: this.props.email,
name: this.props.name,
client: clientInfo,
sessionId: this.getMcpSessionId(),
}),
},
],
}
},
)
// 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 result = await client.getDocuments(containerTags, 1, 10)
const memoryCount = result.documents.reduce(
(sum, d) => sum + d.memoryEntries.length,
0,
)
const textParts = [
`Memory Graph: ${result.documents.length} documents, ${memoryCount} memories`,
]
if (effectiveContainerTag) {
textParts.push(`Project: ${effectiveContainerTag}`)
}
return {
content: [{ type: "text" as const, text: textParts.join(". ") }],
structuredContent: {
containerTag: effectiveContainerTag,
documents: result.documents,
totalCount: result.pagination.totalItems,
},
}
} 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 documents (pagination)
registerAppTool(
this.server,
"fetch-graph-data",
{
description: "Fetch documents with memories for graph display",
inputSchema: z.object({
containerTag: z.string().optional(),
page: z.number().optional().default(1),
limit: z.number().optional().default(10),
}),
_meta: {
ui: {
resourceUri: memoryGraphResourceUri,
visibility: ["app"],
},
},
},
// @ts-expect-error - zod type inference issue with MCP SDK
async (args: {
containerTag?: string
page?: 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.getDocuments(
containerTags,
args.page,
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",
{
description:
"User profile and preferences for system context injection. Returns a formatted system message with user's stable preferences and recent activity.",
//argsSchema: contextPromptSchema.shape, TODO: commenting out for now as it will add more friction to the user
},
// @ts-expect-error - zod type inference issue with MCP SDK
async (args: ContextPromptArgs) => {
try {
const { includeRecent = true } = args
const containerTag = (args as { containerTag?: string }).containerTag
const client = this.getClient(containerTag)
const profileResult = await client.getProfile()
const parts: string[] = []
parts.push(
"**Important:** Whenever the user shares informative facts, preferences, personal details, or any memory-worthy information, use the `memory` tool to save it to Supermemory. This helps maintain context across conversations.",
)
parts.push("")
if (
profileResult.profile.static.length > 0 ||
(includeRecent && profileResult.profile.dynamic.length > 0)
) {
parts.push("## User Context")
}
if (profileResult.profile.static.length > 0) {
parts.push("**Stable Preferences:**")
for (const fact of profileResult.profile.static) {
parts.push(`- ${fact}`)
}
}
if (includeRecent && profileResult.profile.dynamic.length > 0) {
parts.push("\n**Recent Activity:**")
for (const fact of profileResult.profile.dynamic) {
parts.push(`- ${fact}`)
}
}
const contextText =
parts.length > 2
? parts.join("\n")
: "**Important:** Whenever the user shares informative facts, preferences, personal details, or any memory-worthy information, use the `memory` tool to save it to Supermemory. This helps maintain context across conversations.\n\nNo user profile available yet. Start saving memories to build context."
return {
messages: [
{
role: "user",
content: {
type: "text",
text: contextText,
},
},
],
}
} catch (error) {
const message =
error instanceof Error
? error.message
: "An unexpected error occurred"
console.error("Context prompt failed:", error)
return {
messages: [
{
role: "user",
content: {
type: "text",
text: `Error retrieving user context: ${message}`,
},
},
],
}
}
},
)
}
/**
* Get a SupermemoryClient instance configured with the API key
*/
private getClient(containerTag?: string): SupermemoryClient {
if (!this.props) {
throw new Error("Props not initialized")
}
const { apiKey, containerTag: mcpRootContainerTag } = this.props
if (!apiKey) {
throw new Error("Authentication required")
}
const apiUrl = this.env.API_URL || "https://api.supermemory.ai"
return new SupermemoryClient(
apiKey,
containerTag || mcpRootContainerTag,
apiUrl,
)
}
private async handleMemory(args: {
content: string
action?: "save" | "forget"
containerTag?: string
}) {
const { content, action = "save", containerTag } = args
const effectiveContainerTag = containerTag || this.props?.containerTag
try {
const client = this.getClient(effectiveContainerTag)
const clientInfo = await this.getClientInfo()
if (action === "forget") {
const result = await client.forgetMemory(content)
// Track forget event
posthog
.memoryForgot({
userId: this.props?.userId || "unknown",
content_length: content.length,
source: "mcp",
mcp_client_name: clientInfo?.name,
mcp_client_version: clientInfo?.version,
sessionId: this.getMcpSessionId(),
containerTag: result.containerTag,
})
.catch((error) => console.error("PostHog tracking error:", error))
return {
content: [
{
type: "text" as const,
text: `${result.message} in container ${result.containerTag}`,
},
],
}
}
const result = await client.createMemory(content)
if (!this.cachedContainerTags.includes(result.containerTag)) {
await this.refreshContainerTags()
}
// Track memory added event
posthog
.memoryAdded({
type: "note",
project_id: result.containerTag,
content_length: content.length,
source: "mcp",
userId: this.props?.userId || "unknown",
mcp_client_name: clientInfo?.name,
mcp_client_version: clientInfo?.version,
sessionId: this.getMcpSessionId(),
containerTag: result.containerTag,
})
.catch((error) => console.error("PostHog tracking error:", error))
return {
content: [
{
type: "text" as const,
text: `Saved memory (id: ${result.id}) in ${result.containerTag} project`,
},
],
}
} catch (error) {
const message =
error instanceof Error ? error.message : "An unexpected error occurred"
console.error("Memory operation failed:", error)
return {
content: [
{
type: "text" as const,
text: `Error: ${message}`,
},
],
isError: true,
}
}
}
private async handleRecall(args: {
query: string
includeProfile?: boolean
containerTag?: string
}) {
const { query, includeProfile = true, containerTag } = args
try {
const client = this.getClient(containerTag)
const clientInfo = await this.getClientInfo()
const startTime = Date.now()
const searchResult = await client.search(query, 10, undefined, {
searchMode: "hybrid",
include: {
documents: true,
relatedMemories: true,
summaries: false,
chunks: false,
forgottenMemories: false,
},
})
const parts: string[] = []
if (includeProfile) {
const profileResult = await client.getProfile()
if (
profileResult.profile.static.length > 0 ||
profileResult.profile.dynamic.length > 0
) {
parts.push("## User Profile")
if (profileResult.profile.static.length > 0) {
parts.push("**Stable facts:**")
for (const fact of profileResult.profile.static) {
parts.push(`- ${fact}`)
}
}
if (profileResult.profile.dynamic.length > 0) {
parts.push("\n**Recent context:**")
for (const fact of profileResult.profile.dynamic) {
parts.push(`- ${fact}`)
}
}
parts.push("")
}
}
parts.push("## Relevant Memories")
parts.push(
formatMemories(
{
results: searchResult.results as unknown as Array<
Record<string, unknown>
>,
total: searchResult.total,
},
{ includeScores: true, includeLegend: true },
),
)
const endTime = Date.now()
// Track search event
posthog
.memorySearch({
query_length: query.length,
results_count: searchResult.results.length,
search_duration_ms: endTime - startTime,
container_tags_count: 1,
source: "mcp",
userId: this.props?.userId || "unknown",
mcp_client_name: clientInfo?.name,
mcp_client_version: clientInfo?.version,
sessionId: this.getMcpSessionId(),
containerTag: containerTag || this.props?.containerTag,
})
.catch((error) => console.error("PostHog tracking error:", error))
const text = parts.join("\n")
return {
content: [
{
type: "text" as const,
text:
text.length > MAX_RECALL_CHARS
? `${text.slice(0, MAX_RECALL_CHARS)}...`
: text,
},
],
}
} catch (error) {
const message =
error instanceof Error ? error.message : "An unexpected error occurred"
console.error("Recall operation failed:", error)
return {
content: [
{
type: "text" as const,
text: `Error: ${message}`,
},
],
isError: true,
}
}
}
private async getClientInfo(): Promise<
{ name: string; version?: string } | undefined
> {
if (this.clientInfo) {
return this.clientInfo
}
const storedClientInfo = await this.ctx.storage.get<{
name: string
version?: string
}>("clientInfo")
if (storedClientInfo) {
this.clientInfo = storedClientInfo
return this.clientInfo
}
return undefined
}
private getMcpSessionId(): string {
return this.ctx.id.name || "unknown"
}
private async ensureContainerTagsFresh(): Promise<void> {
const now = Date.now()
const needsRefresh =
this.containerTagsLastFetchedAt === null ||
now - this.containerTagsLastFetchedAt > CONTAINER_TAGS_TTL_MS
if (needsRefresh) {
await this.refreshContainerTags()
}
}
private async refreshContainerTags(): Promise<void> {
try {
const client = this.getClient()
this.cachedContainerTags = await client.getProjects()
this.containerTagsLastFetchedAt = Date.now()
} catch (error) {
console.error("Failed to fetch container tags:", error)
}
}
private getContainerTagDescription(): string {
const baseDescription = "Optional project to scope memories"
if (this.cachedContainerTags.length === 0) {
return baseDescription
}
return `${baseDescription}. Available projects: ${this.cachedContainerTags.join(", ")}`
}
}

View file

@ -0,0 +1,115 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
import { McpAgent } from "agents/mcp"
import type { Props } from "../shared/types"
import { buildRbacContext } from "./auth/rbac"
import { SupermemoryClient } from "./client"
import { registerContextPrompt } from "./prompts/context"
import { registerContainerTagsResource } from "./resources/container-tags"
import { registerProfileResource } from "./resources/profile"
import { registerWidgetResource } from "./resources/widget"
import { registerAllTools } from "./tools"
import { errorResult } from "./tools/types"
type Env = {
MCP_SERVER: DurableObjectNamespace
API_URL?: string
AUTH_CACHE?: KVNamespace
}
const DEFAULT_API_URL = "https://api.supermemory.ai"
export class EnterpriseMCP extends McpAgent<Env, unknown, Props> {
private clientInfo: { name: string; version?: string } | null = null
private cachedContainerTagsList: string[] = []
// @ts-expect-error - agents/mcp ships its own bundled @modelcontextprotocol/sdk;
// our installed sdk has a private `_serverInfo` field with a different declaration.
server = new McpServer({
name: "supermemory-enterprise",
version: "1.0.0",
})
async init() {
const stored = await this.ctx.storage.get<{
name: string
version?: string
}>("clientInfo")
if (stored) this.clientInfo = stored
this.server.server.oninitialized = async () => {
const v = this.server.server.getClientVersion()
if (v) {
this.clientInfo = { name: v.name, version: v.version }
await this.ctx.storage.put("clientInfo", this.clientInfo)
}
}
await this.refreshContainerTags()
const rbac = buildRbacContext(this.props)
if (rbac.isRestricted && rbac.assignedTags.length === 1) {
await this.ctx.storage.put(
"activeContainerTag",
rbac.assignedTags[0].containerTag,
)
}
const deps = {
server: this.server,
props: this.props,
rbac,
getClient: (containerTag?: string) => this.getClient(containerTag),
resolveContainerTag: (explicit?: string) =>
this.resolveContainerTag(explicit),
storage: {
get: <T>(key: string) => this.ctx.storage.get<T>(key),
put: <T>(key: string, value: T) => this.ctx.storage.put(key, value),
},
cachedContainerTags: () => this.cachedContainerTagsList,
refreshContainerTags: () => this.refreshContainerTags(),
getClientInfo: () => this.clientInfo,
getMcpSessionId: () => this.ctx.id.name ?? "unknown",
errorResult,
}
registerAllTools(deps)
registerProfileResource(this.server, () => this.getClient())
registerContainerTagsResource(this.server, () => this.getClient())
registerWidgetResource(this.server)
registerContextPrompt(
this.server,
rbac,
(tag) => this.getClient(tag),
(explicit) => this.resolveContainerTag(explicit),
)
}
private getClient(containerTag?: string): SupermemoryClient {
return new SupermemoryClient(
this.props?.apiKey || "",
containerTag || this.props?.containerTag,
this.env.API_URL || DEFAULT_API_URL,
)
}
private async resolveContainerTag(
explicit?: string,
): Promise<string | undefined> {
if (explicit) return explicit
const activeTag = await this.ctx.storage.get<string>("activeContainerTag")
return activeTag || this.props?.containerTag
}
private async refreshContainerTags(): Promise<void> {
try {
const client = this.getClient()
const tags = await client.listContainerTags()
this.cachedContainerTagsList = tags.map((t) => t.containerTag)
} catch (error) {
console.error("Failed to refresh container tags:", error)
}
}
}

View file

@ -0,0 +1,111 @@
import type { AuthUser, ContainerTagAccess } from "."
// ── Key format ────────────────────────────────────────────────────────
// <service>:<kind>:v<version>:<sha256-hex>
//
// Why each segment exists:
// - service: namespaces our keys; safe even if AUTH_CACHE is shared with
// other workers later
// - kind: discriminates this cache from future kinds (e.g.,
// `enterprise-mcp:container-tags:v1:<orgId>`)
// - version: schema version on the value; bump to invalidate every entry
// instantly without flushing the namespace
// - hash: SHA-256 hex of the bearer; deterministic, never reveals
// the raw token
const SERVICE = "enterprise-mcp"
const KIND = "auth"
const CACHE_VERSION = 1 as const
const TTL_SECONDS = 300 // 5 min — matches Better Auth's session cookie cache
interface CachedAuth {
v: typeof CACHE_VERSION
user: AuthUser
cachedAt: number // ms epoch — for observability only; KV owns TTL
}
function cacheKey(hash: string): string {
return `${SERVICE}:${KIND}:v${CACHE_VERSION}:${hash}`
}
export async function tokenHash(token: string): Promise<string> {
const buf = new TextEncoder().encode(token)
const hash = await crypto.subtle.digest("SHA-256", buf)
return [...new Uint8Array(hash)]
.map((b) => b.toString(16).padStart(2, "0"))
.join("")
}
// ── Validators ────────────────────────────────────────────────────────
// Defensive: KV is a black box. Validate every read so a malformed entry
// (schema drift across deploys, a manual KV write, anything) becomes a
// cache miss instead of a runtime crash or corrupted props downstream.
function isValidAuthUser(u: unknown): u is AuthUser {
if (typeof u !== "object" || u === null) return false
const o = u as Record<string, unknown>
if (typeof o.userId !== "string" || o.userId.length === 0) return false
if (typeof o.apiKey !== "string" || o.apiKey.length === 0) return false
// Optional fields — only check shape if present.
if (o.email !== undefined && typeof o.email !== "string") return false
if (o.name !== undefined && typeof o.name !== "string") return false
if (o.role !== undefined && typeof o.role !== "string") return false
if (o.accessType !== undefined && typeof o.accessType !== "string")
return false
if (o.containerTags !== undefined && o.containerTags !== null) {
if (!Array.isArray(o.containerTags)) return false
for (const tag of o.containerTags) {
if (typeof tag !== "object" || tag === null) return false
const t = tag as Record<string, unknown>
if (typeof t.containerTag !== "string") return false
if (typeof t.permission !== "string") return false
}
}
return true
}
function isValidCached(c: unknown): c is CachedAuth {
if (typeof c !== "object" || c === null) return false
const o = c as Record<string, unknown>
if (o.v !== CACHE_VERSION) return false
if (typeof o.cachedAt !== "number") return false
return isValidAuthUser(o.user)
}
// ── Public API (signature unchanged from previous version) ────────────
export async function getCachedAuth(
kv: KVNamespace,
token: string,
): Promise<AuthUser | null> {
const key = cacheKey(await tokenHash(token))
// kv.get<unknown>(key, "json") returns null on missing OR unparseable JSON.
const cached = await kv.get<unknown>(key, "json")
if (!isValidCached(cached)) return null
return cached.user
}
export async function putCachedAuth(
kv: KVNamespace,
token: string,
user: AuthUser,
): Promise<void> {
// Never cache invalid data. Treat upstream-returned but-malformed user as
// a non-event for the cache; the request still succeeds since middleware
// already received `user` from the validator.
if (!isValidAuthUser(user)) return
const key = cacheKey(await tokenHash(token))
const value: CachedAuth = {
v: CACHE_VERSION,
user,
cachedAt: Date.now(),
}
await kv.put(key, JSON.stringify(value), { expirationTtl: TTL_SECONDS })
}
// Re-export for any future callers that want the validators directly.
export { isValidAuthUser, isValidCached, cacheKey, CACHE_VERSION, TTL_SECONDS }
// Avoid unused-import warning in some toolchains while keeping the type
// narrowing referenced by the validator.
export type { ContainerTagAccess }

View file

@ -0,0 +1,158 @@
/**
* Authentication via API introspection.
* Validates OAuth tokens and API keys by calling the main Supermemory API.
* Extended with RBAC data (role, accessType, containerTags).
*/
import type { ContainerTagAccess } from "../../shared/types"
export type { ContainerTagAccess }
export interface AuthUser {
userId: string
apiKey: string
email?: string
name?: string
role?: string // "owner" | "admin" | "member"
accessType?: string // "full" | "restricted"
containerTags?: ContainerTagAccess[] | null
}
export function isApiKey(token: string): boolean {
return token.startsWith("sm_")
}
export async function validateApiKey(
apiKey: string,
apiUrl: string,
): Promise<AuthUser | null> {
try {
const response = await fetch(`${apiUrl}/v3/session`, {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
})
if (!response.ok) {
const status = response.status
if (status === 401) {
console.error("API key validation failed: Invalid or expired")
} else if (status === 403) {
console.error("API key validation failed: Blocked or forbidden")
} else if (status === 429) {
console.error("API key validation failed: Rate limited")
} else {
console.error("API key validation failed:", status)
}
return null
}
const data = (await response.json()) as {
user?: { id?: string; email?: string; name?: string }
role?: string
accessType?: string
containerTags?: ContainerTagAccess[] | null
error?: string
} | null
if (!data?.user?.id) {
console.error("Missing user.id in session response")
return null
}
return {
userId: data.user.id,
apiKey,
email: data.user.email,
name: data.user.name,
role: data.role,
accessType: data.accessType,
containerTags: data.containerTags,
}
} catch (error) {
console.error("API key validation error:", error)
return null
}
}
export async function validateOAuthToken(
token: string,
apiUrl: string,
): Promise<AuthUser | null> {
try {
const response = await fetch(`${apiUrl}/v3/mcp/session-with-key`, {
method: "GET",
headers: { Authorization: `Bearer ${token}` },
})
if (!response.ok) {
const status = response.status
if (status === 401) {
console.error("Token validation failed: Invalid or expired")
} else if (status === 403) {
console.error("Token validation failed: Blocked or forbidden")
} else if (status === 429) {
console.error("Token validation failed: Rate limited")
} else {
console.error("Token validation failed:", status)
}
return null
}
const data = (await response.json()) as {
userId?: string
apiKey?: string
email?: string
name?: string
error?: string
} | null
if (!data?.userId || !data?.apiKey) {
console.error("Missing userId or apiKey in session response")
return null
}
// Fetch RBAC data using the exchanged API key.
// Fail-closed: if RBAC fetch fails or is non-OK, return null. A
// transient failure here previously left accessType=undefined, which
// `buildRbacContext` interpreted as "not restricted" — silently
// elevating a restricted user.
let role: string | undefined
let accessType: string | undefined
let containerTags: ContainerTagAccess[] | null = null
try {
const rbacResponse = await fetch(`${apiUrl}/v3/session`, {
method: "GET",
headers: { Authorization: `Bearer ${data.apiKey}` },
})
if (!rbacResponse.ok) {
console.error("RBAC fetch returned non-OK:", rbacResponse.status)
return null
}
const rbac = (await rbacResponse.json()) as {
role?: string
accessType?: string
containerTags?: ContainerTagAccess[] | null
}
role = rbac.role
accessType = rbac.accessType
containerTags = rbac.containerTags ?? null
} catch (err) {
console.error("Failed to fetch RBAC data:", err)
return null
}
return {
userId: data.userId,
apiKey: data.apiKey,
email: data.email,
name: data.name,
role,
accessType,
containerTags,
}
} catch (error) {
console.error("Token validation error:", error)
return null
}
}

View file

@ -0,0 +1,42 @@
import type { ContainerTagAccess, Props } from "../../shared/types"
export interface RbacContext {
isRestricted: boolean
assignedTags: ContainerTagAccess[]
writeTags: ContainerTagAccess[]
hasWriteAccess: boolean
hasRootContainerTag: boolean
// Defense-in-depth: short-circuit before hitting the API so we surface a
// clear permission-denied to the model instead of a downstream 403.
// API still enforces authoritatively via containerTagGuard.
canRead: (containerTag: string) => boolean
canWrite: (containerTag: string) => boolean
}
export function buildRbacContext(props: Props | undefined): RbacContext {
const isRestricted = props?.accessType === "restricted"
const assignedTags: ContainerTagAccess[] = props?.assignedTags ?? []
const writeTags = assignedTags.filter((t) => t.permission === "write")
const hasWriteAccess = !isRestricted || writeTags.length > 0
const hasRootContainerTag = !!props?.containerTag
const canRead = (containerTag: string): boolean => {
if (!isRestricted) return true
return assignedTags.some((t) => t.containerTag === containerTag)
}
const canWrite = (containerTag: string): boolean => {
if (!isRestricted) return true
return writeTags.some((t) => t.containerTag === containerTag)
}
return {
isRestricted,
assignedTags,
writeTags,
hasWriteAccess,
hasRootContainerTag,
canRead,
canWrite,
}
}

View file

@ -1,31 +1,36 @@
import Supermemory from "supermemory"
import type {
ContainerTag,
DocumentMemoryEntry,
DocumentsApiResponse,
DocumentWithMemories,
} from "../../shared/types"
const MAX_CHARS = 200000 // ~50k tokens (character-based limit)
const MAX_CHARS = 200000
const DEFAULT_PROJECT_ID = "sm_project_default"
interface MemoryRichFields {
metadata?: Record<string, unknown> | null
updatedAt?: string
context?: Record<string, unknown>
documents?: Array<Record<string, unknown>>
isAggregated?: boolean
export type {
ContainerTag,
DocumentMemoryEntry,
DocumentWithMemories,
DocumentsApiResponse,
}
export type Memory =
| ({
| {
id: string
memory: string
similarity: number
title?: string
content?: string
} & MemoryRichFields)
| ({
}
| {
id: string
chunk: string
similarity: number
title?: string
content?: string
} & MemoryRichFields)
}
export interface SearchResult {
results: Memory[]
@ -33,19 +38,6 @@ export interface SearchResult {
timing: number
}
export interface SearchOptions {
searchMode?: "memories" | "hybrid" | "documents"
rerank?: boolean
rewriteQuery?: boolean
include?: {
documents?: boolean
relatedMemories?: boolean
summaries?: boolean
chunks?: boolean
forgottenMemories?: boolean
}
}
export interface Profile {
static: string[]
dynamic: string[]
@ -56,53 +48,6 @@ export interface ProfileResponse {
searchResults?: SearchResult
}
export interface Project {
id: string
name: string
containerTag: string
createdAt: string
updatedAt: string
isExperimental: boolean
documentCount?: number
}
// Documents API types
export interface DocumentMemoryEntry {
id: string
memory: string
spaceId: string
isStatic?: boolean
isLatest?: boolean
isForgotten?: boolean
forgetAfter?: string | null
forgetReason?: string | null
version?: number
parentMemoryId?: string | null
rootMemoryId?: string | null
createdAt: string
updatedAt: string
}
export interface DocumentWithMemories {
id: string
title: string | null
summary?: string | null
type: string
createdAt: string
updatedAt: string
memoryEntries: DocumentMemoryEntry[]
}
export interface DocumentsApiResponse {
documents: DocumentWithMemories[]
pagination: {
currentPage: number
limit: number
totalItems: number
totalPages: number
}
}
export function getMemoryText(m: Memory): string {
return "memory" in m ? m.memory : m.chunk
}
@ -111,7 +56,6 @@ function limitByChars(text: string, maxChars = MAX_CHARS): string {
return text.length > maxChars ? `${text.slice(0, maxChars)}...` : text
}
// Type for SDK search result item
interface SDKResult {
id: string
memory?: string
@ -119,11 +63,7 @@ interface SDKResult {
content?: string
similarity: number
title?: string
metadata?: Record<string, unknown> | null
updatedAt?: string
context?: Record<string, unknown>
documents?: Array<Record<string, unknown>>
isAggregated?: boolean
context?: string
}
export class SupermemoryClient {
@ -146,7 +86,6 @@ export class SupermemoryClient {
this.containerTag = containerTag || DEFAULT_PROJECT_ID
}
// Create memory using SDK
async createMemory(
content: string,
): Promise<{ id: string; status: string; containerTag: string }> {
@ -154,9 +93,7 @@ export class SupermemoryClient {
const result = await this.client.add({
content,
containerTag: this.containerTag,
metadata: {
sm_source: "mcp",
},
metadata: { sm_source: "enterprise-mcp" },
})
return {
id: result.id,
@ -168,59 +105,48 @@ export class SupermemoryClient {
}
}
// Delete/forget memory - try exact match first, then semantic search
async forgetMemory(
content: string,
): Promise<{ success: boolean; message: string; containerTag: string }> {
try {
// Try exact content matching first
try {
const result = await this.client.memories.forget({
content: content,
content,
containerTag: this.containerTag,
})
return {
success: true,
message: `Successfully forgot memory (exact match) with ID: ${result.id}`,
containerTag: this.containerTag,
}
} catch (error: unknown) {
// If not 404, it's a real error - re-throw it
const status =
error && typeof error === "object" && "status" in error
? (error as Record<string, unknown>).status
: undefined
if (status !== 404) {
throw error
}
// Otherwise continue to semantic search fallback
if (status !== 404) throw error
}
// Fallback to semantic search if exact match fails
const SIMILARITY_THRESHOLD = 0.85 // High threshold - only very similar memories
const SIMILARITY_THRESHOLD = 0.85
const searchResult = await this.search(content, 5, SIMILARITY_THRESHOLD)
if (searchResult.results.length === 0) {
return {
success: false,
message: `No matching memory found to forget. Tried exact match and semantic search with similarity threshold ${SIMILARITY_THRESHOLD}.`,
message: "No matching memory found to forget.",
containerTag: this.containerTag,
}
}
// Only actual memories (not chunks) can be forgotten
const memoryToDelete = searchResult.results.find((r) => "memory" in r)
if (!memoryToDelete) {
return {
success: false,
message:
"No matching memory found to forget (only document chunks matched in semantic search).",
message: "No matching memory found (only chunks matched).",
containerTag: this.containerTag,
}
}
// Delete using the ID from semantic search
await this.client.memories.forget({
id: memoryToDelete.id,
containerTag: this.containerTag,
@ -230,7 +156,7 @@ export class SupermemoryClient {
getMemoryText(memoryToDelete) || memoryToDelete.content || ""
return {
success: true,
message: `Forgot similar memory (semantic match, similarity: ${memoryToDelete.similarity.toFixed(2)}): "${limitByChars(memoryText, 100)}"`,
message: `Forgot similar memory (similarity: ${memoryToDelete.similarity.toFixed(2)}): "${limitByChars(memoryText, 100)}"`,
containerTag: this.containerTag,
}
} catch (error) {
@ -238,37 +164,29 @@ export class SupermemoryClient {
}
}
// Search memories using SDK
async search(
query: string,
limit = 10,
threshold?: number,
options?: SearchOptions,
): Promise<SearchResult> {
try {
const result = await this.client.search.memories({
q: query,
limit,
containerTag: this.containerTag,
searchMode: options?.searchMode ?? "hybrid",
threshold, // Optional threshold parameter
rerank: options?.rerank,
rewriteQuery: options?.rewriteQuery,
include: options?.include,
searchMode: "hybrid",
threshold,
})
const results: Memory[] = (result.results as SDKResult[]).map((r) => {
const text = limitByChars(r.content || r.memory || r.chunk || "")
const text = limitByChars(
r.content || r.memory || r.chunk || r.context || "",
)
const base = {
id: r.id,
similarity: r.similarity,
title: r.title,
content: r.content,
metadata: r.metadata,
updatedAt: r.updatedAt,
context: r.context,
documents: r.documents,
isAggregated: r.isAggregated,
}
if (r.chunk && !r.memory) {
return { ...base, chunk: text }
@ -276,17 +194,12 @@ export class SupermemoryClient {
return { ...base, memory: text }
})
return {
results,
total: result.total,
timing: result.timing,
}
return { results, total: result.total, timing: result.timing }
} catch (error) {
this.handleError(error)
}
}
// Get user profile using SDK
async getProfile(query?: string): Promise<ProfileResponse> {
try {
const result = await this.client.profile({
@ -304,16 +217,16 @@ export class SupermemoryClient {
if (result.searchResults) {
response.searchResults = {
results: (result.searchResults.results as SDKResult[]).map((r) => {
const text = limitByChars(r.content || r.memory || r.chunk || "")
const text = limitByChars(
r.content || r.memory || r.chunk || r.context || "",
)
const base = {
id: r.id,
similarity: r.similarity,
title: r.title,
content: r.content,
}
if (r.chunk && !r.memory) {
return { ...base, chunk: text }
}
if (r.chunk && !r.memory) return { ...base, chunk: text }
return { ...base, memory: text }
}),
total: result.searchResults.total,
@ -327,10 +240,9 @@ export class SupermemoryClient {
}
}
// Get projects list
async getProjects(): Promise<string[]> {
async listContainerTags(): Promise<ContainerTag[]> {
try {
const response = await fetch(`${this.apiUrl}/v3/projects`, {
const response = await fetch(`${this.apiUrl}/v3/container-tags/list`, {
method: "GET",
headers: {
Authorization: `Bearer ${this.bearerToken}`,
@ -342,23 +254,22 @@ export class SupermemoryClient {
if (response.status === 401) {
throw new Error("Authentication failed. Please re-authenticate.")
}
throw new Error(`Failed to fetch projects: ${response.statusText}`)
throw new Error(
`Failed to fetch container tags: ${response.statusText}`,
)
}
const data = (await response.json()) as {
projects: Project[]
}
return data.projects?.map((p) => p.containerTag) || []
const data = (await response.json()) as ContainerTag[]
return Array.isArray(data) ? data : []
} catch (error) {
this.handleError(error)
}
}
// Fetch documents with their memory entries
async getDocuments(
containerTags?: string[],
page = 1,
limit = 10,
limit = 200,
): Promise<DocumentsApiResponse> {
try {
const response = await fetch(`${this.apiUrl}/v3/documents/documents`, {
@ -386,20 +297,56 @@ export class SupermemoryClient {
}
}
async uploadFile(
fileData: ArrayBuffer,
fileName: string,
mimeType: string,
containerTag?: string,
): Promise<{ id: string; status: string }> {
try {
const formData = new FormData()
const blob = new Blob([fileData], { type: mimeType })
formData.append("file", blob, fileName)
if (containerTag) {
formData.append("containerTags", containerTag)
}
formData.append(
"metadata",
JSON.stringify({ sm_source: "enterprise-mcp" }),
)
const response = await fetch(`${this.apiUrl}/v3/documents/file`, {
method: "POST",
headers: {
Authorization: `Bearer ${this.bearerToken}`,
},
body: formData,
})
if (!response.ok) {
const text = await response.text()
throw Object.assign(new Error(text || "Upload failed"), {
status: response.status,
})
}
const result = (await response.json()) as { id: string; status: string }
return result
} catch (error) {
this.handleError(error)
}
}
private handleError(error: unknown): never {
// Handle network/fetch errors
if (error instanceof TypeError) {
if (
error.message.includes("fetch") ||
error.message.includes("network")
) {
throw new Error(
"Network error. Please check your connection and try again.",
)
throw new Error("Network error. Please check your connection.")
}
}
// Handle HTTP status errors from SDK/fetch
if (error && typeof error === "object" && "status" in error) {
const status = (error as { status: number }).status
const message =
@ -408,38 +355,25 @@ export class SupermemoryClient {
switch (status) {
case 400:
case 422:
throw new Error(
message || "Invalid request parameters. Please check your input.",
)
throw new Error(message || "Invalid request. Check your input.")
case 401:
throw new Error("Authentication failed. Please re-authenticate.")
case 402:
throw new Error("Memory limit reached. Upgrade at supermemory.ai")
case 403:
throw new Error(
"Access forbidden. Your account may be restricted or blocked.",
)
throw new Error("Access forbidden.")
case 404:
throw new Error("Memory not found. It may have been deleted.")
throw new Error("Not found.")
case 429:
throw new Error(
"Rate limit exceeded. Please wait a moment and try again.",
)
throw new Error("Rate limit exceeded. Please wait and try again.")
default:
if (status >= 500) {
throw new Error(
"Server error. The service may be temporarily unavailable. Please try again later.",
)
throw new Error("Server error. Please try again later.")
}
}
}
// Re-throw Error instances as-is
if (error instanceof Error) {
throw error
}
// Wrap unknown errors
throw new Error(`An unexpected error occurred: ${String(error)}`)
if (error instanceof Error) throw error
throw new Error(`Unexpected error: ${String(error)}`)
}
}

View file

@ -1,38 +1,63 @@
import { Hono } from "hono"
import { cors } from "hono/cors"
import { Hono, type Context } from "hono"
import { SupermemoryMCP } from "./server"
import { isApiKey, validateApiKey, validateOAuthToken } from "./auth"
import { initPosthog } from "./posthog"
import type { ContentfulStatusCode } from "hono/utils/http-status"
import type { Props } from "../shared/types"
import { EnterpriseMCP } from "./agent"
import {
type AuthUser,
isApiKey,
validateApiKey,
validateOAuthToken,
} from "./auth"
import { getCachedAuth, putCachedAuth } from "./auth/cache"
type Bindings = {
MCP_SERVER: DurableObjectNamespace
API_URL?: string
MCP_URL?: string
POSTHOG_API_KEY?: string
AUTH_CACHE?: KVNamespace
}
type Props = {
userId: string
apiKey: string
containerTag?: string
email?: string
name?: string
// Per-request validation, but cached against an introspected result keyed
// by SHA-256(token). Hot path: ~5ms KV lookup. Cold path: ~400ms upstream
// introspection (same as today). TTL 5 min — matches Better Auth's cookie
// cache. Fail-open if KV is unavailable so we never hard-fail auth.
async function resolveAuth(
token: string,
apiUrl: string,
kv: KVNamespace | undefined,
): Promise<AuthUser | null> {
if (kv) {
try {
const cached = await getCachedAuth(kv, token)
if (cached) {
console.log("[auth] cache-hit")
return cached
}
} catch (err) {
console.warn("[auth] cache-error:", err)
}
}
console.log("[auth] cache-miss")
const user = isApiKey(token)
? await validateApiKey(token, apiUrl)
: await validateOAuthToken(token, apiUrl)
if (user && kv) {
// Best-effort write; never block the request on cache write
void putCachedAuth(kv, token, user).catch((err) =>
console.warn("[auth] cache-write-error:", err),
)
}
return user
}
export type { Props }
const app = new Hono<{ Bindings: Bindings }>()
const DEFAULT_API_URL = "https://api.supermemory.ai"
const DEFAULT_MCP_URL = "https://mcp.supermemory.ai"
const mcpBaseUrl = (c: Context<{ Bindings: Bindings }>) => {
if (c.env.MCP_URL) return c.env.MCP_URL.replace(/\/$/, "")
const host = c.req.header("x-forwarded-host") || c.req.header("host")
const proto = c.req.header("x-forwarded-proto") || "https"
return host ? `${proto}://${host}` : DEFAULT_MCP_URL
}
// CORS
app.use(
"*",
cors({
@ -51,78 +76,79 @@ app.use(
}),
)
app.use("*", async (c, next) => {
initPosthog(c.env.POSTHOG_API_KEY)
await next()
})
app.get("/", (c) => {
return c.json({
name: "supermemory-mcp",
version: "4.0.0",
description: "Give your AI a memory",
name: "enterprise-mcp",
version: "1.0.0",
description: "Supermemory Enterprise MCP — AI memory for teams",
docs: "https://docs.supermemory.ai/mcp",
})
})
// MCP clients use this to discover the authorization server
const protectedResourceHandler = (c: Context<{ Bindings: Bindings }>) => {
// OAuth discovery: resource metadata
app.get("/.well-known/oauth-protected-resource", (c) => {
const apiUrl = c.env.API_URL || DEFAULT_API_URL
// Derive resource URL from the incoming request so it matches whatever
// host the client connected to (tunnel, prod, localhost, etc.)
const host = c.req.header("x-forwarded-host") || c.req.header("host")
const proto = c.req.header("x-forwarded-proto") || "https"
const resourceUrl = host
? `${proto}://${host}`
: "https://enterprise-mcp.supermemory.ai"
return c.json({
resource: `${mcpBaseUrl(c)}/mcp`,
resource: resourceUrl,
authorization_servers: [apiUrl],
scopes_supported: ["openid", "profile", "email", "offline_access"],
bearer_methods_supported: ["header"],
resource_documentation: "https://docs.supermemory.ai/mcp",
})
}
app.get("/.well-known/oauth-protected-resource", protectedResourceHandler)
app.get("/.well-known/oauth-protected-resource/mcp", protectedResourceHandler)
})
// Proxy endpoint for MCP clients that don't follow the spec correctly
// Some clients look for oauth-authorization-server on the MCP server domain
// instead of following the authorization_servers array
// OAuth discovery: proxy authorization server metadata
app.get("/.well-known/oauth-authorization-server", async (c) => {
const apiUrl = c.env.API_URL || DEFAULT_API_URL
try {
// Fetch the authorization server metadata from the main API
const response = await fetch(
`${apiUrl}/.well-known/oauth-authorization-server`,
)
if (!response.ok) {
return c.json(
{ error: "Failed to fetch authorization server metadata" },
{ status: response.status as ContentfulStatusCode },
)
}
const metadata = await response.json()
return c.json(metadata)
} catch (error) {
console.error("Error fetching OAuth authorization server metadata:", error)
console.error("Error fetching OAuth metadata:", error)
return c.json({ error: "Internal server error" }, 500)
}
})
const mcpHandler = SupermemoryMCP.serve("/mcp", {
const mcpHandler = EnterpriseMCP.serve("/mcp", {
binding: "MCP_SERVER",
corsOptions: {
origin: "*",
methods: "GET, POST, DELETE, OPTIONS",
headers:
"Content-Type, Authorization, x-sm-project, Accept, Mcp-Session-Id, MCP-Protocol-Version, Last-Event-ID",
headers: "Content-Type, Authorization, x-sm-project",
},
})
const handleMcpRequest = async (c: Context<{ Bindings: Bindings }>) => {
app.all("/mcp/*", async (c) => {
const authHeader = c.req.header("Authorization")
const token = authHeader?.replace(/^Bearer\s+/i, "")
const containerTag = c.req.header("x-sm-project")
const apiUrl = c.env.API_URL || DEFAULT_API_URL
const resourceMetadataUrl = `${mcpBaseUrl(c)}/.well-known/oauth-protected-resource/mcp`
// Build absolute resource_metadata URL from incoming request (works
// behind tunnels where the scheme/host differ from localhost)
const reqHost = c.req.header("x-forwarded-host") || c.req.header("host") || ""
const reqProto = c.req.header("x-forwarded-proto") || "https"
const resourceMetadataUrl = reqHost
? `${reqProto}://${reqHost}/.well-known/oauth-protected-resource`
: "/.well-known/oauth-protected-resource"
if (!token) {
return new Response("Unauthorized", {
@ -135,32 +161,17 @@ const handleMcpRequest = async (c: Context<{ Bindings: Bindings }>) => {
})
}
let authUser: {
userId: string
apiKey: string
email?: string
name?: string
} | null = null
if (isApiKey(token)) {
console.log("Authenticating with API key")
authUser = await validateApiKey(token, apiUrl)
} else {
console.log("Authenticating with OAuth token")
authUser = await validateOAuthToken(token, apiUrl)
}
const authUser = await resolveAuth(token, apiUrl, c.env.AUTH_CACHE)
if (!authUser) {
const errorMessage = isApiKey(token)
? "Unauthorized: Invalid or expired API key"
: "Unauthorized: Invalid or expired token"
return new Response(
JSON.stringify({
jsonrpc: "2.0",
error: {
code: -32000,
message: errorMessage,
message: isApiKey(token)
? "Invalid or expired API key"
: "Invalid or expired token",
},
id: null,
}),
@ -176,7 +187,6 @@ const handleMcpRequest = async (c: Context<{ Bindings: Bindings }>) => {
)
}
// Create execution context with authenticated user props
const ctx = {
...c.executionCtx,
props: {
@ -185,16 +195,15 @@ const handleMcpRequest = async (c: Context<{ Bindings: Bindings }>) => {
containerTag,
email: authUser.email,
name: authUser.name,
role: authUser.role,
accessType: authUser.accessType,
assignedTags: authUser.containerTags,
} satisfies Props,
} as ExecutionContext & { props: Props }
return mcpHandler.fetch(c.req.raw, c.env, ctx)
}
})
app.all("/mcp", handleMcpRequest)
app.all("/mcp/*", handleMcpRequest)
// Export the Durable Object class for Cloudflare Workers
export { SupermemoryMCP }
export { EnterpriseMCP }
export default app

View file

@ -0,0 +1,108 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
import { z } from "zod"
import type { RbacContext } from "../auth/rbac"
import type { SupermemoryClient } from "../client"
export function registerContextPrompt(
server: McpServer,
rbac: RbacContext,
getClient: (tag?: string) => SupermemoryClient,
resolveContainerTag: (explicit?: string) => Promise<string | undefined>,
) {
const containerTagField: Record<string, z.ZodTypeAny> =
rbac.hasRootContainerTag
? {}
: {
containerTag: z
.string()
.max(128, "Container tag exceeds maximum length")
.optional(),
}
const argsSchema = {
includeRecent: z.boolean().optional().default(true),
...containerTagField,
}
server.registerPrompt(
"context",
{
description: "Get user context including profile and workspace info",
argsSchema,
},
async (rawArgs) => {
const args = rawArgs as {
includeRecent?: boolean
containerTag?: string
}
try {
if (args.containerTag && !rbac.canRead(args.containerTag)) {
return {
messages: [
{
role: "user" as const,
content: {
type: "text" as const,
text: `No access to container tag '${args.containerTag}'.`,
},
},
],
}
}
const effectiveTag = await resolveContainerTag(args.containerTag)
const client = getClient(effectiveTag)
const profileResult = await client.getProfile()
const parts: string[] = []
if (profileResult.profile.static.length > 0) {
parts.push("## About the user")
for (const fact of profileResult.profile.static) {
parts.push(`- ${fact}`)
}
}
if (
args.includeRecent !== false &&
profileResult.profile.dynamic.length > 0
) {
parts.push("\n## Recent context")
for (const fact of profileResult.profile.dynamic) {
parts.push(`- ${fact}`)
}
}
if (effectiveTag) {
parts.push(`\n## Active workspace: ${effectiveTag}`)
}
return {
messages: [
{
role: "user" as const,
content: {
type: "text" as const,
text:
parts.length > 0
? parts.join("\n")
: "No user context available yet.",
},
},
],
}
} catch {
return {
messages: [
{
role: "user" as const,
content: {
type: "text" as const,
text: "Unable to load user context.",
},
},
],
}
}
},
)
}

View file

@ -0,0 +1,26 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
import type { SupermemoryClient } from "../client"
export function registerContainerTagsResource(
server: McpServer,
getClient: () => SupermemoryClient,
) {
server.registerResource(
"My Container Tags",
"supermemory://container-tags",
{},
async () => {
const client = getClient()
const containerTags = await client.listContainerTags()
return {
contents: [
{
uri: "supermemory://container-tags",
mimeType: "application/json",
text: JSON.stringify({ containerTags }, null, 2),
},
],
}
},
)
}

View file

@ -0,0 +1,45 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
import type { SupermemoryClient } from "../client"
export function registerProfileResource(
server: McpServer,
getClient: () => SupermemoryClient,
) {
server.registerResource(
"User Profile",
"supermemory://profile",
{},
async () => {
const client = getClient()
const profileResult = await client.getProfile()
const parts: string[] = ["# User Profile\n"]
if (profileResult.profile.static.length > 0) {
parts.push("## Stable Preferences")
for (const fact of profileResult.profile.static) {
parts.push(`- ${fact}`)
}
}
if (profileResult.profile.dynamic.length > 0) {
parts.push("\n## Recent Activity")
for (const fact of profileResult.profile.dynamic) {
parts.push(`- ${fact}`)
}
}
return {
contents: [
{
uri: "supermemory://profile",
mimeType: "text/plain",
text:
parts.length > 1
? parts.join("\n")
: "No profile yet. Start saving memories.",
},
],
}
},
)
}

View file

@ -0,0 +1,49 @@
import {
RESOURCE_MIME_TYPE,
registerAppResource,
} from "@modelcontextprotocol/ext-apps/server"
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
import enterpriseAppHtml from "../../../dist/src/widget/index.html"
import { ENTERPRISE_RESOURCE_URI } from "../../shared/types"
const CSP_DOMAINS = [
"https://esm.sh",
"https://fonts.googleapis.com",
"https://fonts.gstatic.com",
] as const
const RESOURCE_UI_META = {
prefersBorder: true,
csp: {
resourceDomains: [...CSP_DOMAINS],
connectDomains: [...CSP_DOMAINS],
},
}
export function registerWidgetResource(server: McpServer) {
registerAppResource(
server,
"Enterprise MCP UI",
ENTERPRISE_RESOURCE_URI,
// Listing-level metadata: hosts use this when discovering resources
// before invoking the read callback. Mirrors the read response below
// so prefetch/connect-time decisions match what the host will get.
{
mimeType: RESOURCE_MIME_TYPE,
_meta: { ui: RESOURCE_UI_META },
},
// Read response: per spec, content-item `_meta.ui` takes precedence
// over the listing-level value. Set both to the same object so behavior
// is consistent regardless of which path the host inspects.
async () => ({
contents: [
{
uri: ENTERPRISE_RESOURCE_URI,
mimeType: RESOURCE_MIME_TYPE,
text: enterpriseAppHtml,
_meta: { ui: RESOURCE_UI_META },
},
],
}),
)
}

View file

@ -0,0 +1,69 @@
import { z } from "zod"
import type { ToolDeps } from "./types"
export function register(deps: ToolDeps) {
const containerTagField: Record<string, z.ZodTypeAny> = deps.rbac
.hasRootContainerTag
? {}
: {
containerTag: z
.string()
.max(128, "Container tag exceeds maximum length")
.optional(),
}
const inputSchema = {
content: z
.string()
.max(200000, "Content exceeds maximum length")
.describe("The memory content to save or forget"),
action: z.enum(["save", "forget"]).optional().default("save"),
...containerTagField,
}
deps.server.registerTool(
"add_memory",
{
description:
"Add (save) or forget a memory in the user's ACTIVE workspace. Defaults to 'save'. The target workspace is the one the user selected via select-workspace; pass containerTag only to override it. Use 'forget' when information is outdated or the user asks to remove it.",
inputSchema,
},
async (rawArgs) => {
const args = rawArgs as {
content: string
action?: "save" | "forget"
containerTag?: string
}
try {
if (args.containerTag && !deps.rbac.canWrite(args.containerTag)) {
return deps.errorResult(
new Error(
`No write access to container tag '${args.containerTag}'.`,
),
)
}
const effectiveTag = await deps.resolveContainerTag(args.containerTag)
const client = deps.getClient(effectiveTag)
if (args.action === "forget") {
const result = await client.forgetMemory(args.content)
return {
content: [{ type: "text" as const, text: result.message }],
}
}
const result = await client.createMemory(args.content)
return {
content: [
{
type: "text" as const,
text: `Memory saved (ID: ${result.id}, workspace: ${result.containerTag})`,
},
],
}
} catch (error) {
return deps.errorResult(error)
}
},
)
}

View file

@ -0,0 +1,56 @@
import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"
import { z } from "zod"
import { ENTERPRISE_RESOURCE_URI } from "../../shared/types"
import type { ToolDeps } from "./types"
export function register(deps: ToolDeps) {
registerAppTool(
deps.server,
"fetch-graph-data",
{
description: "Fetch documents with memories for graph display",
inputSchema: {
containerTag: z.string().optional(),
page: z.number().optional().default(1),
limit: z.number().optional().default(200),
},
_meta: {
ui: {
resourceUri: ENTERPRISE_RESOURCE_URI,
visibility: ["app"],
},
},
},
async (rawArgs) => {
const args = rawArgs as {
containerTag?: string
page?: number
limit?: number
}
try {
if (args.containerTag && !deps.rbac.canRead(args.containerTag)) {
return deps.errorResult(
new Error(
`No read access to container tag '${args.containerTag}'.`,
),
)
}
const effectiveTag = await deps.resolveContainerTag(args.containerTag)
const client = deps.getClient(effectiveTag)
const containerTags = effectiveTag ? [effectiveTag] : undefined
const data = await client.getDocuments(
containerTags,
args.page,
args.limit,
)
return {
content: [{ type: "text" as const, text: JSON.stringify(data) }],
structuredContent: data,
}
} catch (error) {
return deps.errorResult(error)
}
},
)
}

View file

@ -0,0 +1,45 @@
import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"
import { z } from "zod"
import { ENTERPRISE_RESOURCE_URI, type ViewMessage } from "../../shared/types"
import type { ToolDeps } from "./types"
export function register(deps: ToolDeps) {
registerAppTool(
deps.server,
"guided-save",
{
title: "Add Memory",
description: "Save information to memory with an interactive form.",
inputSchema: {
prefill: z.string().optional().describe("Optional content to prefill"),
},
_meta: { ui: { resourceUri: ENTERPRISE_RESOURCE_URI } },
},
async (args) => {
const prefill = (args as { prefill?: string }).prefill
const activeTag = await deps.storage.get<string>("activeContainerTag")
let writableTags: string[]
if (deps.rbac.isRestricted) {
writableTags = deps.rbac.writeTags.map((t) => t.containerTag)
} else {
const tags = await deps.getClient().listContainerTags()
writableTags = tags.map((t) => t.containerTag)
}
const sc: ViewMessage = {
view: "save",
activeTag,
writableTags,
prefill,
}
return {
content: [
{ type: "text" as const, text: "Opening memory save form..." },
],
structuredContent: sc,
}
},
)
}

View file

@ -0,0 +1,33 @@
import * as addMemory from "./add-memory"
import * as fetchGraphData from "./fetch-graph-data"
import * as guidedSave from "./guided-save"
import * as listContainerTags from "./list-container-tags"
import * as memoryGraph from "./memory-graph"
import * as saveMemory from "./save-memory"
import * as searchMemory from "./search-memory"
import * as selectWorkspace from "./select-workspace"
import * as setActiveTag from "./set-active-tag"
import type { ToolDeps } from "./types"
import * as uploadFile from "./upload-file"
import * as uploadFileSubmit from "./upload-file-submit"
import * as whoAmI from "./who-am-i"
export function registerAllTools(deps: ToolDeps) {
// Always available
searchMemory.register(deps)
listContainerTags.register(deps)
whoAmI.register(deps)
selectWorkspace.register(deps)
setActiveTag.register(deps)
memoryGraph.register(deps)
fetchGraphData.register(deps)
// Write-gated (RBAC)
if (deps.rbac.hasWriteAccess) {
addMemory.register(deps)
guidedSave.register(deps)
saveMemory.register(deps)
uploadFile.register(deps)
uploadFileSubmit.register(deps)
}
}

View file

@ -0,0 +1,46 @@
import type { ToolDeps } from "./types"
export function register(deps: ToolDeps) {
deps.server.registerTool(
"listContainerTags",
{
description:
"List available container tags for organizing memories. Returns name, identifier, emoji, document/memory counts, and last activity time per tag. The API auto-filters this list to tags the caller has access to.",
inputSchema: {},
},
async () => {
try {
const tags = await deps.getClient().listContainerTags()
if (tags.length === 0) {
return {
content: [
{
type: "text" as const,
text: "No container tags found.",
},
],
}
}
const lines = tags.map((t) => {
const display = t.emoji ? `${t.emoji} ${t.name}` : t.name
const counts = `(${t.documentCount} docs, ${t.memoryCount} memories)`
return `- ${display} [${t.containerTag}] ${counts}`
})
return {
content: [
{
type: "text" as const,
text: `Available container tags:\n${lines.join("\n")}`,
},
],
structuredContent: { containerTags: tags },
}
} catch (error) {
return deps.errorResult(error)
}
},
)
}

View file

@ -0,0 +1,67 @@
import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"
import { z } from "zod"
import { ENTERPRISE_RESOURCE_URI, type ViewMessage } from "../../shared/types"
import type { ToolDeps } from "./types"
export function register(deps: ToolDeps) {
const inputSchema: Record<string, z.ZodTypeAny> = deps.rbac
.hasRootContainerTag
? {}
: {
containerTag: z
.string()
.max(128, "Container tag exceeds maximum length")
.optional(),
}
registerAppTool(
deps.server,
"memory-graph",
{
title: "Memory Graph",
description:
"Visualize the user's memory graph as an interactive force-directed graph.",
inputSchema,
_meta: { ui: { resourceUri: ENTERPRISE_RESOURCE_URI } },
},
async (rawArgs) => {
try {
const explicit = (rawArgs as { containerTag?: string }).containerTag
if (explicit && !deps.rbac.canRead(explicit)) {
return deps.errorResult(
new Error(`No read access to container tag '${explicit}'.`),
)
}
const effectiveTag = await deps.resolveContainerTag(explicit)
const client = deps.getClient(effectiveTag)
const containerTags = effectiveTag ? [effectiveTag] : undefined
const result = await client.getDocuments(containerTags, 1, 200)
const memoryCount = result.documents.reduce(
(sum, d) => sum + d.memoryEntries.length,
0,
)
const sc: ViewMessage = {
view: "graph",
containerTag: effectiveTag,
documents: result.documents,
totalCount: result.pagination.totalItems,
}
return {
content: [
{
type: "text" as const,
text: `Memory Graph: ${result.documents.length} documents, ${memoryCount} memories${effectiveTag ? `. Workspace: ${effectiveTag}` : ""}`,
},
],
structuredContent: sc,
}
} catch (error) {
return deps.errorResult(error)
}
},
)
}

View file

@ -0,0 +1,51 @@
import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"
import { z } from "zod"
import { ENTERPRISE_RESOURCE_URI, type ViewMessage } from "../../shared/types"
import type { ToolDeps } from "./types"
export function register(deps: ToolDeps) {
registerAppTool(
deps.server,
"save-memory",
{
description: "Save content to memory",
inputSchema: {
content: z.string().min(1),
containerTag: z.string().min(1),
},
_meta: {
ui: {
resourceUri: ENTERPRISE_RESOURCE_URI,
visibility: ["app"],
},
},
},
async (rawArgs) => {
const args = rawArgs as { content: string; containerTag: string }
try {
if (!deps.rbac.canWrite(args.containerTag)) {
return deps.errorResult(
new Error(
`No write access to container tag '${args.containerTag}'.`,
),
)
}
const client = deps.getClient(args.containerTag)
const result = await client.createMemory(args.content)
const sc: ViewMessage = {
view: "save-success",
id: result.id,
containerTag: args.containerTag,
}
return {
content: [
{ type: "text" as const, text: `Memory saved: ${result.id}` },
],
structuredContent: sc,
}
} catch (error) {
return deps.errorResult(error)
}
},
)
}

View file

@ -0,0 +1,90 @@
import { z } from "zod"
import { getMemoryText } from "../client"
import type { ToolDeps } from "./types"
export function register(deps: ToolDeps) {
const containerTagField: Record<string, z.ZodTypeAny> = deps.rbac
.hasRootContainerTag
? {}
: {
containerTag: z
.string()
.max(128, "Container tag exceeds maximum length")
.optional(),
}
const inputSchema = {
query: z
.string()
.max(1000, "Query exceeds maximum length")
.describe("The search query to find relevant memories"),
includeProfile: z.boolean().optional().default(true),
...containerTagField,
}
deps.server.registerTool(
"search_memory",
{
description:
"Search the user's memories with a natural-language query. Returns relevant memories plus their profile summary.",
inputSchema,
},
async (rawArgs) => {
const args = rawArgs as {
query: string
includeProfile?: boolean
containerTag?: string
}
try {
if (args.containerTag && !deps.rbac.canRead(args.containerTag)) {
return deps.errorResult(
new Error(
`No read access to container tag '${args.containerTag}'.`,
),
)
}
const effectiveTag = await deps.resolveContainerTag(args.containerTag)
const client = deps.getClient(effectiveTag)
const parts: string[] = []
if (args.includeProfile !== false) {
const profileResult = await client.getProfile(args.query)
if (profileResult.profile.static.length > 0) {
parts.push("## Profile")
for (const fact of profileResult.profile.static) {
parts.push(`- ${fact}`)
}
}
if (profileResult.profile.dynamic.length > 0) {
parts.push("\n## Recent context")
for (const fact of profileResult.profile.dynamic) {
parts.push(`- ${fact}`)
}
}
}
const searchResult = await client.search(args.query)
if (searchResult.results.length > 0) {
parts.push("\n## Matching memories")
for (const result of searchResult.results) {
const text = getMemoryText(result)
const similarity = (result.similarity * 100).toFixed(0)
parts.push(`- [${similarity}%] ${text}`)
}
} else {
parts.push("\nNo matching memories found.")
}
return {
content: [{ type: "text" as const, text: parts.join("\n") }],
}
} catch (error) {
return deps.errorResult(error)
}
},
)
}

View file

@ -0,0 +1,44 @@
import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"
import { ENTERPRISE_RESOURCE_URI, type ViewMessage } from "../../shared/types"
import type { ToolDeps } from "./types"
export function register(deps: ToolDeps) {
registerAppTool(
deps.server,
"select-workspace",
{
title: "Select Workspace",
description:
"Choose which container tag to work in. Shows available container tags as interactive cards.",
inputSchema: {},
_meta: { ui: { resourceUri: ENTERPRISE_RESOURCE_URI } },
},
async () => {
try {
const client = deps.getClient()
const tags = await client.listContainerTags()
const activeTag = await deps.storage.get<string>("activeContainerTag")
const sc: ViewMessage = {
view: "picker",
containerTags: tags,
activeTag,
assignedTags: deps.rbac.isRestricted ? deps.rbac.assignedTags : null,
}
return {
content: [
{
type: "text" as const,
text: `${tags.length} container tags available. Select one to set your active context.`,
},
],
structuredContent: sc,
}
} catch (error) {
return deps.errorResult(error)
}
},
)
}

View file

@ -0,0 +1,45 @@
import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"
import { z } from "zod"
import { ENTERPRISE_RESOURCE_URI, type ViewMessage } from "../../shared/types"
import type { ToolDeps } from "./types"
export function register(deps: ToolDeps) {
registerAppTool(
deps.server,
"set-active-tag",
{
description: "Set the active container tag for this session",
inputSchema: {
containerTag: z.string().min(1),
},
_meta: {
ui: {
resourceUri: ENTERPRISE_RESOURCE_URI,
visibility: ["app"],
},
},
},
async (args) => {
const containerTag = (args as { containerTag: string }).containerTag
if (!deps.rbac.canRead(containerTag)) {
return deps.errorResult(
new Error(`No access to container tag '${containerTag}'.`),
)
}
await deps.storage.put("activeContainerTag", containerTag)
const sc: ViewMessage = {
view: "confirmation",
containerTag,
}
return {
content: [
{
type: "text" as const,
text: `Active workspace set to ${containerTag}`,
},
],
structuredContent: sc,
}
},
)
}

View file

@ -0,0 +1,35 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
import type { Props } from "../../shared/types"
import type { RbacContext } from "../auth/rbac"
import type { SupermemoryClient } from "../client"
// Dependencies passed to every tool's register() function.
// Keep this surface small — tools should read this rather than reach into the agent.
export interface ToolDeps {
server: McpServer
props: Props | undefined
rbac: RbacContext
getClient: (containerTag?: string) => SupermemoryClient
resolveContainerTag: (explicit?: string) => Promise<string | undefined>
storage: {
get: <T>(key: string) => Promise<T | undefined>
put: <T>(key: string, value: T) => Promise<void>
}
cachedContainerTags: () => string[]
refreshContainerTags: () => Promise<void>
getClientInfo: () => { name: string; version?: string } | null
getMcpSessionId: () => string
errorResult: (error: unknown) => {
content: { type: "text"; text: string }[]
isError: true
}
}
export function errorResult(error: unknown) {
const message =
error instanceof Error ? error.message : "An unexpected error occurred"
return {
content: [{ type: "text" as const, text: `Error: ${message}` }],
isError: true as const,
}
}

View file

@ -0,0 +1,76 @@
import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"
import { z } from "zod"
import { ENTERPRISE_RESOURCE_URI, type ViewMessage } from "../../shared/types"
import type { ToolDeps } from "./types"
export function register(deps: ToolDeps) {
registerAppTool(
deps.server,
"upload-file-submit",
{
description: "Submit a file upload",
inputSchema: {
fileData: z.string().describe("Base64-encoded file content"),
fileName: z.string(),
mimeType: z.string(),
containerTag: z.string().min(1),
},
_meta: {
ui: {
resourceUri: ENTERPRISE_RESOURCE_URI,
visibility: ["app"],
},
},
},
async (rawArgs) => {
const args = rawArgs as {
fileData: string
fileName: string
mimeType: string
containerTag: string
}
try {
if (!deps.rbac.canWrite(args.containerTag)) {
return deps.errorResult(
new Error(
`No write access to container tag '${args.containerTag}'.`,
),
)
}
const binaryString = atob(args.fileData)
const bytes = new Uint8Array(binaryString.length)
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i)
}
const client = deps.getClient(args.containerTag)
const result = await client.uploadFile(
bytes.buffer as ArrayBuffer,
args.fileName,
args.mimeType,
args.containerTag,
)
const sc: ViewMessage = {
view: "upload-success",
id: result.id,
fileName: args.fileName,
containerTag: args.containerTag,
}
return {
content: [
{
type: "text" as const,
text: `File uploaded: ${args.fileName}${result.id}`,
},
],
structuredContent: sc,
}
} catch (error) {
return deps.errorResult(error)
}
},
)
}

View file

@ -0,0 +1,40 @@
import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"
import { ENTERPRISE_RESOURCE_URI, type ViewMessage } from "../../shared/types"
import type { ToolDeps } from "./types"
export function register(deps: ToolDeps) {
registerAppTool(
deps.server,
"upload-file",
{
title: "Upload File",
description: "Upload a file (PDF, text, image, video) to memory.",
inputSchema: {},
_meta: { ui: { resourceUri: ENTERPRISE_RESOURCE_URI } },
},
async () => {
const activeTag = await deps.storage.get<string>("activeContainerTag")
let writableTags: string[]
if (deps.rbac.isRestricted) {
writableTags = deps.rbac.writeTags.map((t) => t.containerTag)
} else {
const tags = await deps.getClient().listContainerTags()
writableTags = tags.map((t) => t.containerTag)
}
const sc: ViewMessage = {
view: "upload",
activeTag,
writableTags,
}
return {
content: [
{ type: "text" as const, text: "Opening file upload form..." },
],
structuredContent: sc,
}
},
)
}

View file

@ -0,0 +1,34 @@
import type { ToolDeps } from "./types"
export function register(deps: ToolDeps) {
deps.server.registerTool(
"whoAmI",
{
description: "Get current user info, role, and workspace context",
inputSchema: {},
},
async () => {
const activeTag = await deps.storage.get<string>("activeContainerTag")
return {
content: [
{
type: "text" as const,
text: JSON.stringify({
userId: deps.props?.userId,
email: deps.props?.email,
name: deps.props?.name,
role: deps.props?.role ?? "unknown",
accessType: deps.props?.accessType ?? "full",
activeWorkspace: activeTag ?? null,
assignedTags: deps.rbac.isRestricted
? deps.rbac.assignedTags
: null,
client: deps.getClientInfo(),
sessionId: deps.getMcpSessionId(),
}),
},
],
}
},
)
}

View file

@ -0,0 +1,111 @@
// Shared types — imported by both server tools and widget views.
// Single source of truth for the server↔widget contract.
export interface ContainerTagAccess {
containerTag: string
permission: string // "read" | "write"
}
export interface ContainerTag {
id: string
name: string
containerTag: string
createdAt: string
updatedAt: string
isExperimental: boolean
emoji?: string
isNova: boolean
documentCount: number
memoryCount: number
lastActivityAt: string | null
}
export interface DocumentMemoryEntry {
id: string
memory: string
spaceId: string
isStatic?: boolean
isLatest?: boolean
isForgotten?: boolean
forgetAfter?: string | null
forgetReason?: string | null
version?: number
parentMemoryId?: string | null
rootMemoryId?: string | null
memoryRelations?: Record<string, string>
createdAt: string
updatedAt: string
}
export interface DocumentWithMemories {
id: string
title: string | null
summary?: string | null
type: string
createdAt: string
updatedAt: string
memoryEntries: DocumentMemoryEntry[]
}
export interface DocumentsApiResponse {
documents: DocumentWithMemories[]
pagination: {
currentPage: number
limit: number
totalItems: number
totalPages: number
}
}
// ViewMessage — discriminated union returned by app tools as `structuredContent`.
// The widget uses an exhaustive switch on `view` to dispatch to the correct view component.
// Adding a new view here is a compile error in App.tsx until the case is handled.
export type ViewMessage =
| {
view: "picker"
containerTags: ContainerTag[]
activeTag?: string | null
assignedTags?: ContainerTagAccess[] | null
}
| { view: "confirmation"; containerTag: string }
| {
view: "save"
activeTag?: string | null
writableTags: string[]
prefill?: string
}
| { view: "save-success"; id: string; containerTag: string }
| {
view: "upload"
activeTag?: string | null
writableTags: string[]
}
| {
view: "upload-success"
id: string
fileName: string
containerTag: string
}
| {
view: "graph"
documents: DocumentWithMemories[]
totalCount: number
containerTag?: string
}
export type ViewName = ViewMessage["view"]
// Auth context passed from the OAuth/API-key middleware into the McpAgent via ctx.props.
export type Props = {
userId: string
apiKey: string
containerTag?: string
email?: string
name?: string
role?: string
accessType?: string
assignedTags?: ContainerTagAccess[] | null
}
// MCP resource URI for the widget bundle.
export const ENTERPRISE_RESOURCE_URI = "ui://enterprise/app.html"

View file

@ -1,46 +0,0 @@
* {
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);
}

View file

@ -1,303 +0,0 @@
#graph {
width: 100%;
height: 600px;
border-radius: 12px;
overflow: hidden;
background-image: radial-gradient(
circle,
var(--text-muted) 0.5px,
transparent 0.5px
);
background-size: 16px 16px;
}
/* Loading */
#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 badge */
#stats {
position: fixed;
top: 12px;
left: 12px;
font-size: 12px;
color: var(--text-muted);
z-index: 10;
padding: 6px 12px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -2px rgba(0, 0, 0, 0.1);
}
/* Popup */
#popup {
display: none;
position: fixed;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 12px;
padding: 14px;
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -4px rgba(0, 0, 0, 0.1);
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 8px;
border-radius: 6px;
margin-bottom: 8px;
}
#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: 13px;
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 - vertical stack, bottom left, above legend */
#controls {
position: fixed;
bottom: 72px;
left: 16px;
display: flex;
flex-direction: column;
gap: 4px;
z-index: 15;
}
#controls > button {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 9999px;
color: var(--text-muted);
font-size: 12px;
cursor: pointer;
padding: 8px 12px;
white-space: nowrap;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
transition: opacity 0.15s;
min-width: 0;
}
#controls > button:hover {
opacity: 0.85;
}
#controls > button kbd {
font-family: inherit;
font-size: 10px;
font-weight: 500;
background: var(--border);
padding: 2px 6px;
border-radius: 4px;
color: var(--text-muted);
}
#zoom-row {
display: flex;
align-items: center;
gap: 2px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 9999px;
padding: 6px 10px;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
}
#zoom-row button {
width: 20px;
height: 20px;
padding: 0;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg-secondary);
color: var(--text-muted);
font-size: 13px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: none;
transition: opacity 0.15s;
}
#zoom-row button:hover {
opacity: 0.85;
}
#zoom-display {
font-size: 12px;
color: var(--text-muted);
min-width: 36px;
text-align: center;
user-select: none;
padding: 0 4px;
}
/* Legend - bottom left, single expandable card, 214px wide */
#legend {
position: fixed;
bottom: 16px;
left: 16px;
width: 214px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 12px;
z-index: 20;
font-size: 12px;
color: var(--text-muted);
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -2px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
#legend-toggle {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
cursor: pointer;
user-select: none;
font-weight: 600;
font-size: 12px;
color: var(--text);
}
.legend-chevron {
transition: transform 0.2s;
transform: rotate(90deg);
}
#legend.collapsed .legend-chevron {
transform: rotate(0deg);
}
#legend-body {
padding: 0 12px 12px;
overflow: hidden;
max-height: 400px;
transition:
max-height 0.25s ease,
padding 0.25s ease,
opacity 0.2s;
opacity: 1;
}
#legend.collapsed #legend-body {
max-height: 0;
padding: 0 12px;
opacity: 0;
}
.legend-group {
padding: 4px 0;
}
.legend-divider {
height: 1px;
background: var(--border);
margin: 2px 0;
}
.legend-row {
display: flex;
align-items: center;
gap: 8px;
padding: 3px 0;
font-size: 12px;
}
.legend-count {
margin-left: auto;
font-size: 11px;
opacity: 0.7;
}
.legend-line {
width: 18px;
height: 0;
border-top: 1.5px solid;
flex-shrink: 0;
}
.legend-line.dashed {
border-top-style: dashed;
}

View file

@ -1,934 +0,0 @@
/**
* 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
spaceId: string
isLatest: boolean
isForgotten: boolean
forgetAfter: string | null
forgetReason: string | null
version: number
parentMemoryId: string | null
rootMemoryId: string | null
createdAt: string
updatedAt: string
relation?: "updates" | "extends" | "derives" | null
memoryRelations?: Record<string, "updates" | "extends" | "derives"> | null
}
interface GraphApiDocument {
id: string
title: string | null
summary: string | null
type: string
createdAt: string
updatedAt: string
memoryEntries: GraphApiMemory[]
}
interface ToolResultData {
containerTag?: string
documents: GraphApiDocument[]
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: "derives" | "updates" | "extends"
}
// =============================================================================
// Constants
// =============================================================================
const MEMORY_BORDER = {
forgotten: "#EF4444",
expiring: "#F59E0B",
recent: "#10B981",
default: "#3B73B8",
}
const EDGE_COLORS = {
dark: { derives: "#FBBF24", updates: "#A78BFA", extends: "#38BDF8" },
light: { derives: "#FBBF24", updates: "#A78BFA", extends: "#38BDF8" },
}
const EDGE_OPACITY: Record<string, number> = {
derives: 0.4,
updates: 0.7,
extends: 0.55,
}
const EDGE_WIDTH: Record<string, number> = {
derives: 1.2,
updates: 2,
extends: 1.5,
}
// Node sizes
const MEM_RADIUS = 12
const DOC_SIZE = 28
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
let hoveredNode: GraphNode | null = null
// =============================================================================
// DOM References (elements are guaranteed to exist in mcp-app.html)
// =============================================================================
// biome-ignore lint/style/noNonNullAssertion: DOM element guaranteed to exist in HTML
const container = document.getElementById("graph")!
// biome-ignore lint/style/noNonNullAssertion: DOM element guaranteed to exist in HTML
const popup = document.getElementById("popup")!
// biome-ignore lint/style/noNonNullAssertion: DOM element guaranteed to exist in HTML
const popupType = document.getElementById("popup-type")!
// biome-ignore lint/style/noNonNullAssertion: DOM element guaranteed to exist in HTML
const popupTitle = document.getElementById("popup-title")!
// biome-ignore lint/style/noNonNullAssertion: DOM element guaranteed to exist in HTML
const popupContent = document.getElementById("popup-content")!
// biome-ignore lint/style/noNonNullAssertion: DOM element guaranteed to exist in HTML
const popupMeta = document.getElementById("popup-meta")!
// biome-ignore lint/style/noNonNullAssertion: DOM element guaranteed to exist in HTML
const loadingEl = document.getElementById("loading")!
// biome-ignore lint/style/noNonNullAssertion: DOM element guaranteed to exist in HTML
const statsEl = document.getElementById("stats")!
// biome-ignore lint/style/noNonNullAssertion: DOM element guaranteed to exist in HTML
const zoomInBtn = document.getElementById("zoom-in")!
// biome-ignore lint/style/noNonNullAssertion: DOM element guaranteed to exist in HTML
const zoomOutBtn = document.getElementById("zoom-out")!
// biome-ignore lint/style/noNonNullAssertion: DOM element guaranteed to exist in HTML
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
}
/** Simple hash to get deterministic initial positions from doc ID */
function hashCode(s: string): number {
let h = 0
for (let i = 0; i < s.length; i++) {
h = (Math.imul(31, h) + s.charCodeAt(i)) | 0
}
return h
}
function initialPosition(id: string, spread: number): { x: number; y: number } {
const h = hashCode(id)
const angle = ((h & 0xffff) / 0xffff) * Math.PI * 2
const radius = (((h >>> 16) & 0xffff) / 0xffff) * spread
return { x: Math.cos(angle) * radius, y: Math.sin(angle) * radius }
}
function transformData(data: ToolResultData): {
nodes: GraphNode[]
links: GraphLink[]
} {
const nodes: GraphNode[] = []
const links: GraphLink[] = []
const SPREAD = 50
// Pre-populate all node IDs so edge targets are always resolvable
// regardless of iteration order.
const nodeIds = new Set<string>()
for (const doc of data.documents) {
nodeIds.add(doc.id)
for (const mem of doc.memoryEntries) nodeIds.add(mem.id)
}
for (const doc of data.documents) {
const pos = initialPosition(doc.id, SPREAD)
nodes.push({
id: doc.id,
nodeType: "document",
title: doc.title || "Untitled",
summary: doc.summary,
docType: doc.type,
createdAt: doc.createdAt,
memoryCount: doc.memoryEntries.length,
x: pos.x,
y: pos.y,
} as DocumentNode)
const memCount = doc.memoryEntries.length
for (let i = 0; i < memCount; i++) {
// biome-ignore lint/style/noNonNullAssertion: index is always valid within loop bounds
const mem = doc.memoryEntries[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: pos.x + Math.cos(angle) * CLUSTER_SPREAD,
y: pos.y + Math.sin(angle) * CLUSTER_SPREAD,
} as MemoryNode)
// Derives link (doc -> memory)
links.push({ source: doc.id, target: mem.id, edgeType: "derives" })
// Memory-to-memory relation edges from backend data.
// Uses memoryRelations as primary source, falls back to parentMemoryId.
// Keep in sync with packages/memory-graph/src/hooks/use-graph-data.ts
let relations: Record<string, string> = {}
if (
// Defensive: data comes from structuredContent cast, may be unexpected type
mem.memoryRelations &&
typeof mem.memoryRelations === "object" &&
Object.keys(mem.memoryRelations).length > 0
) {
relations = mem.memoryRelations
} else if (mem.parentMemoryId) {
relations = { [mem.parentMemoryId]: "updates" }
}
for (const [targetId, relationType] of Object.entries(relations)) {
if (!nodeIds.has(targetId)) continue
const edgeType =
relationType === "updates" ||
relationType === "extends" ||
relationType === "derives"
? relationType
: "updates"
links.push({ source: targetId, target: mem.id, edgeType })
}
}
}
return { nodes, links }
}
// =============================================================================
// Drawing
// =============================================================================
function hexPath(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
radius: number,
) {
ctx.beginPath()
for (let i = 0; i < 6; i++) {
const angle = (Math.PI / 3) * i - Math.PI / 6
ctx.lineTo(x + radius * Math.cos(angle), y + radius * Math.sin(angle))
}
ctx.closePath()
}
function lightenColor(hex: string, amount: number): string {
const h = hex.replace("#", "")
if (h.length !== 6) return hex
const r = Math.min(
255,
Number.parseInt(h.substring(0, 2), 16) + Math.round(255 * amount),
)
const g = Math.min(
255,
Number.parseInt(h.substring(2, 4), 16) + Math.round(255 * amount),
)
const b = Math.min(
255,
Number.parseInt(h.substring(4, 6), 16) + Math.round(255 * amount),
)
return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`
}
function drawMemoryNode(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
radius: number,
screenSize: number,
borderColor: string,
isHovered: boolean,
isSelected: boolean,
isForgotten: boolean,
isLatest: boolean,
) {
const accent = isDark ? "#3B73B8" : "#2563eb"
const memFill = isDark ? "#0D2034" : "#E8F0FE"
// Dot mode at very small screen sizes
if (screenSize < 8) {
const r = Math.max(2, screenSize * 0.45)
// Glow halo
ctx.save()
ctx.globalAlpha = 0.25
ctx.beginPath()
ctx.arc(x, y, r * 2.5, 0, Math.PI * 2)
ctx.fillStyle = borderColor
ctx.fill()
ctx.restore()
// Dot
ctx.beginPath()
ctx.arc(x, y, r, 0, Math.PI * 2)
ctx.fillStyle = memFill
ctx.fill()
ctx.strokeStyle = borderColor
ctx.lineWidth = 1.5
ctx.stroke()
return
}
// Superseded (non-latest) memory: dimmed with dashed border
if (!isLatest && !isSelected && !isHovered) {
ctx.save()
ctx.globalAlpha = 0.5
hexPath(ctx, x, y, radius)
ctx.fillStyle = memFill
ctx.fill()
ctx.strokeStyle = borderColor
ctx.lineWidth = 1
ctx.setLineDash([3, 3])
ctx.stroke()
ctx.setLineDash([])
// Strikethrough
const sr = radius * 0.55
ctx.beginPath()
ctx.moveTo(x - sr, y - sr)
ctx.lineTo(x + sr, y + sr)
ctx.strokeStyle = isDark ? "#94a3b8" : "#64748b"
ctx.lineWidth = 1.5
ctx.stroke()
ctx.restore()
return
}
// Shadow for hover/selected
if (isSelected || isHovered) {
ctx.save()
ctx.shadowColor = isSelected ? accent : isDark ? "#3B73B8" : "#2563eb"
ctx.shadowBlur = isSelected ? 18 : 12
hexPath(ctx, x, y, radius)
ctx.fillStyle = memFill
ctx.fill()
ctx.restore()
// Dashed glow ring
ctx.save()
const scale = isSelected ? 1.15 : 1.1
hexPath(ctx, x, y, radius * scale)
ctx.strokeStyle = accent
ctx.lineWidth = isSelected ? 2 : 1.5
ctx.globalAlpha = isSelected ? 0.8 : 0.5
ctx.setLineDash(isSelected ? [3, 3] : [4, 4])
ctx.stroke()
ctx.setLineDash([])
ctx.restore()
}
// Main hexagon
hexPath(ctx, x, y, radius)
ctx.fillStyle = isHovered ? (isDark ? "#112840" : "#dbeafe") : memFill
ctx.fill()
ctx.strokeStyle = isSelected ? accent : borderColor
ctx.lineWidth = isSelected ? 2.5 : isHovered ? 2 : 1.5
ctx.stroke()
// Forgotten X icon
if (isForgotten && radius > 7) {
const iconR = radius * 0.3
ctx.save()
ctx.lineCap = "round"
ctx.beginPath()
ctx.moveTo(x - iconR, y - iconR)
ctx.lineTo(x + iconR, y + iconR)
ctx.moveTo(x + iconR, y - iconR)
ctx.lineTo(x - iconR, y + iconR)
ctx.strokeStyle = MEMORY_BORDER.forgotten
ctx.lineWidth = Math.max(1.5, radius / 10)
ctx.stroke()
ctx.restore()
}
}
function drawDocumentNode(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
size: number,
screenSize: number,
isHovered: boolean,
isSelected: boolean,
) {
const accent = isDark ? "#3B73B8" : "#2563eb"
const docFill = isDark ? "#1B1F24" : "#F1F5F9"
const docStroke = isDark ? "#2A2F36" : "#CBD5E1"
const half = size / 2
const cornerR = Math.round(8 * (size / 50))
// Dot mode at very small screen sizes
if (screenSize < 8) {
const s = Math.max(3, screenSize)
ctx.fillStyle = docFill
ctx.fillRect(x - s / 2, y - s / 2, s, s)
return
}
// Shadow for hover/selected
if (isSelected || isHovered) {
ctx.save()
ctx.shadowColor = accent
ctx.shadowBlur = isSelected ? 16 : 10
ctx.beginPath()
ctx.roundRect(x - half, y - half, size, size, cornerR)
ctx.fillStyle = docFill
ctx.fill()
ctx.restore()
// Dashed glow ring
ctx.save()
const scale = isSelected ? 1.15 : 1.1
const gh = (size * scale) / 2
ctx.beginPath()
ctx.roundRect(
x - gh,
y - gh,
size * scale,
size * scale,
Math.round(8 * ((size * scale) / 50)),
)
ctx.strokeStyle = accent
ctx.lineWidth = isSelected ? 2 : 1.5
ctx.globalAlpha = isSelected ? 0.8 : 0.5
ctx.setLineDash(isSelected ? [3, 3] : [4, 4])
ctx.stroke()
ctx.setLineDash([])
ctx.restore()
}
// Outer rect with gradient
ctx.beginPath()
ctx.roundRect(x - half, y - half, size, size, cornerR)
const gradient = ctx.createLinearGradient(
x - half,
y - half,
x + half,
y + half,
)
gradient.addColorStop(0, docFill)
gradient.addColorStop(1, lightenColor(docFill, 0.08))
ctx.fillStyle = gradient
ctx.fill()
ctx.strokeStyle = isSelected || isHovered ? accent : docStroke
ctx.lineWidth = isSelected ? 2.5 : isHovered ? 1.5 : 1
ctx.stroke()
// Inner area
const innerSize = size * 0.72
const innerHalf = innerSize / 2
const innerCornerR = Math.round(6 * (size / 50))
ctx.beginPath()
ctx.roundRect(
x - innerHalf,
y - innerHalf,
innerSize,
innerSize,
innerCornerR,
)
ctx.fillStyle = isDark ? "#13161A" : "#E2E8F0"
ctx.fill()
// Document icon (page with fold)
const iconS = size * 0.35
const iconColor = isDark ? "#3B73B8" : "#2563eb"
const w = iconS * 0.7
const h = iconS * 0.85
const fold = iconS * 0.2
const ix = x - w / 2
const iy = y - h / 2
ctx.save()
ctx.strokeStyle = iconColor
ctx.lineWidth = Math.max(1, iconS / 12)
ctx.lineCap = "round"
ctx.lineJoin = "round"
ctx.beginPath()
ctx.moveTo(ix, iy)
ctx.lineTo(ix + w - fold, iy)
ctx.lineTo(ix + w, iy + fold)
ctx.lineTo(ix + w, iy + h)
ctx.lineTo(ix, iy + h)
ctx.closePath()
ctx.stroke()
ctx.beginPath()
ctx.moveTo(ix + w - fold, iy)
ctx.lineTo(ix + w - fold, iy + fold)
ctx.lineTo(ix + w, iy + fold)
ctx.stroke()
ctx.restore()
}
// =============================================================================
// Force Graph Setup
// =============================================================================
const graph = new ForceGraph<GraphNode, GraphLink>(container)
.nodeId("id")
.nodeCanvasObject(
(node: GraphNode, ctx: CanvasRenderingContext2D, globalScale: number) => {
// biome-ignore lint/style/noNonNullAssertion: force-graph guarantees x/y during render
const x = node.x!
// biome-ignore lint/style/noNonNullAssertion: force-graph guarantees x/y during render
const y = node.y!
const isHovered = hoveredNode?.id === node.id
const isSelected = selectedNode?.id === node.id
// Dim non-connected nodes when something is selected
if (selectedNode && !isSelected && !isHovered) {
ctx.globalAlpha = 0.3
}
if (node.nodeType === "memory") {
const mem = node as MemoryNode
const screenSize = MEM_RADIUS * 2 * globalScale
drawMemoryNode(
ctx,
x,
y,
MEM_RADIUS,
screenSize,
mem.borderColor,
isHovered,
isSelected,
mem.isForgotten,
mem.isLatest,
)
} else {
const screenSize = DOC_SIZE * globalScale
drawDocumentNode(ctx, x, y, DOC_SIZE, screenSize, isHovered, isSelected)
}
ctx.globalAlpha = 1
},
)
.nodeCanvasObjectMode(() => "replace")
.nodePointerAreaPaint(
(node: GraphNode, color: string, ctx: CanvasRenderingContext2D) => {
ctx.fillStyle = color
ctx.beginPath()
ctx.arc(
// biome-ignore lint/style/noNonNullAssertion: force-graph guarantees x/y during render
node.x!,
// biome-ignore lint/style/noNonNullAssertion: force-graph guarantees x/y during render
node.y!,
node.nodeType === "document" ? DOC_SIZE / 2 + 1 : MEM_RADIUS + 1,
0,
Math.PI * 2,
)
ctx.fill()
},
)
.linkCanvasObject(
(link: GraphLink, ctx: CanvasRenderingContext2D, globalScale: number) => {
const source = link.source as GraphNode
const target = link.target as GraphNode
if (!source.x || !source.y || !target.x || !target.y) return
const { edgeType } = link
const palette = isDark ? EDGE_COLORS.dark : EDGE_COLORS.light
const color = palette[edgeType] || palette.derives
const width = EDGE_WIDTH[edgeType] || 1.2
const opacity = EDGE_OPACITY[edgeType] || 0.4
const isDimmed = !!selectedNode
// Culling: extends edges at very low zoom
if (edgeType === "extends" && globalScale < 0.08) return
const dimFactor = isDimmed ? 0.3 : 1
const isExtends = edgeType === "extends"
// Glow pass (behind main edge)
if (!isDimmed) {
ctx.save()
ctx.globalAlpha = edgeType === "updates" ? opacity * 0.4 : opacity * 0.3
ctx.strokeStyle = color
ctx.lineWidth = edgeType === "updates" ? width + 2 : width + 1.5
if (isExtends) ctx.setLineDash([6, 4])
ctx.beginPath()
ctx.moveTo(source.x, source.y)
ctx.lineTo(target.x, target.y)
ctx.stroke()
if (isExtends) ctx.setLineDash([])
ctx.restore()
}
// Main edge
ctx.save()
ctx.globalAlpha = opacity * dimFactor
ctx.strokeStyle = color
ctx.lineWidth = width
if (isExtends) ctx.setLineDash([6, 4])
ctx.beginPath()
ctx.moveTo(source.x, source.y)
ctx.lineTo(target.x, target.y)
ctx.stroke()
if (isExtends) ctx.setLineDash([])
ctx.restore()
// Arrowhead for updates edges
if (edgeType === "updates") {
const arrowSize = Math.max(6, 8 * globalScale)
const angle = Math.atan2(target.y - source.y, target.x - source.x)
ctx.save()
ctx.globalAlpha = opacity * 0.6 * dimFactor
ctx.fillStyle = color
ctx.beginPath()
ctx.moveTo(target.x, target.y)
ctx.lineTo(
target.x - arrowSize * Math.cos(angle - Math.PI / 6),
target.y - arrowSize * Math.sin(angle - Math.PI / 6),
)
ctx.lineTo(
target.x - arrowSize * Math.cos(angle + Math.PI / 6),
target.y - arrowSize * Math.sin(angle + Math.PI / 6),
)
ctx.closePath()
ctx.fill()
ctx.restore()
}
},
)
.linkCanvasObjectMode(() => "replace")
.onNodeHover((node: GraphNode | null) => {
hoveredNode = node
container.style.cursor = node ? "pointer" : "default"
})
.onNodeClick(handleNodeClick)
.onBackgroundClick(() => hidePopup())
.d3Force(
"charge",
forceManyBody().strength((node: GraphNode) =>
node.nodeType === "document" ? -15 : -200,
),
)
.d3Force(
"link",
forceLink()
.distance((l: GraphLink) => (l.edgeType === "derives" ? 40 : 80))
.strength((l: GraphLink) => {
if (l.edgeType === "derives") return 0.8
if (l.edgeType === "updates") return 1.0
return 0.15 // extends
}),
)
.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"
// Smart quadrant positioning (right > left > below > above)
const rect = popup.getBoundingClientRect()
const gap = 24
const vw = window.innerWidth
const vh = window.innerHeight
let left: number
let top: number
// Try right
if (x + gap + rect.width < vw - 8) {
left = x + gap
} else if (x - gap - rect.width > 8) {
// Try left
left = x - gap - rect.width
} else {
// Fallback center
left = Math.max(8, (vw - rect.width) / 2)
}
if (y - rect.height / 2 > 8 && y + rect.height / 2 < vh - 8) {
top = y - rect.height / 2
} else if (y + gap + rect.height < vh - 8) {
top = y + gap
} else {
top = y - gap - rect.height
}
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.3
// biome-ignore lint/style/noNonNullAssertion: DOM element guaranteed to exist in HTML
const centerBtn = document.getElementById("center-btn")!
// biome-ignore lint/style/noNonNullAssertion: DOM element guaranteed to exist in HTML
const zoomDisplay = document.getElementById("zoom-display")!
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))
centerBtn.addEventListener("click", () => graph.centerAt(0, 0, 400))
// Update zoom display
graph.onZoom(({ k }) => {
zoomDisplay.textContent = `${Math.round(k * 100)}%`
})
document.addEventListener("keydown", (e) => {
const tag = (e.target as HTMLElement).tagName
if (tag === "INPUT" || tag === "TEXTAREA") return
switch (e.key) {
case "Escape":
hidePopup()
break
case "z":
case "Z":
graph.zoomToFit(400, 40)
break
case "c":
case "C":
graph.centerAt(0, 0, 400)
break
case "+":
case "=":
graph.zoom(graph.zoom() * ZOOM_FACTOR, 200)
break
case "-":
case "_":
graph.zoom(graph.zoom() / ZOOM_FACTOR, 200)
break
}
})
// Legend toggle
// biome-ignore lint/style/noNonNullAssertion: DOM element guaranteed to exist in HTML
const legendEl = document.getElementById("legend")!
// biome-ignore lint/style/noNonNullAssertion: DOM element guaranteed to exist in HTML
const legendToggle = document.getElementById("legend-toggle")!
legendToggle.addEventListener("click", () =>
legendEl.classList.toggle("collapsed"),
)
// =============================================================================
// 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`
// Update legend counts
const docCountEl = document.getElementById("legend-doc-count")
const memCountEl = document.getElementById("legend-mem-count")
if (docCountEl) docCountEl.textContent = String(docCount)
if (memCountEl) memCountEl.textContent = String(memCount)
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)
})

102
apps/mcp/src/widget/App.tsx Normal file
View file

@ -0,0 +1,102 @@
import { useEffect } from "react"
import type { ViewMessage } from "../shared/types"
import { useApplyHostTheme } from "./hooks/useApplyHostTheme"
import { useLog } from "./hooks/useLog"
import { useViewState } from "./hooks/useViewState"
import { Confirmation } from "./views/Confirmation"
import { ErrorView } from "./views/Error"
import { Graph } from "./views/Graph"
import { Loading } from "./views/Loading"
import { Picker } from "./views/Picker"
import { Save } from "./views/Save"
import { Success } from "./views/Success"
import { Upload } from "./views/Upload"
export function App() {
useApplyHostTheme()
const log = useLog()
const { state, setView, setError } = useViewState()
useEffect(() => {
if (state.kind === "view") {
log("info", `[app] view → ${state.message.view}`)
} else if (state.kind === "error") {
log("error", `[app] error: ${state.message}`)
}
}, [state, log])
if (state.kind === "loading") return <Loading />
if (state.kind === "error") return <ErrorView message={state.message} />
if (state.kind === "raw") {
return (
<ErrorView message="Received unrecognized response from server. Try again." />
)
}
return renderView(state.message, setView, setError)
}
function renderView(
msg: ViewMessage,
setView: (m: ViewMessage) => void,
setError: (m: string) => void,
) {
switch (msg.view) {
case "picker":
return (
<Picker
activeTag={msg.activeTag}
assignedTags={msg.assignedTags}
containerTags={msg.containerTags}
onAdvance={setView}
onError={setError}
/>
)
case "save":
return (
<Save
activeTag={msg.activeTag}
onAdvance={setView}
onError={setError}
prefill={msg.prefill}
writableTags={msg.writableTags}
/>
)
case "upload":
return (
<Upload
activeTag={msg.activeTag}
onAdvance={setView}
onError={setError}
writableTags={msg.writableTags}
/>
)
case "graph":
return (
<Graph
containerTag={msg.containerTag}
documents={msg.documents}
totalCount={msg.totalCount}
/>
)
case "confirmation":
return <Confirmation containerTag={msg.containerTag} />
case "save-success":
return <Success containerTag={msg.containerTag} id={msg.id} kind="save" />
case "upload-success":
return (
<Success
containerTag={msg.containerTag}
fileName={msg.fileName}
id={msg.id}
kind="upload"
/>
)
default: {
const exhaustive: never = msg
return (
<ErrorView message={`Unhandled view: ${JSON.stringify(exhaustive)}`} />
)
}
}
}

View file

@ -0,0 +1,58 @@
import { Component, type ErrorInfo, type ReactNode } from "react"
import { Button, Stack } from "./design/ui"
import { app } from "./lib/app"
interface Props {
children: ReactNode
}
interface State {
error: Error | null
}
export class ErrorBoundary extends Component<Props, State> {
state: State = { error: null }
static getDerivedStateFromError(error: Error): State {
return { error }
}
componentDidCatch(error: Error, info: ErrorInfo) {
try {
app.sendLog({
level: "error",
logger: "ErrorBoundary",
data: `${error.name}: ${error.message}\n${info.componentStack ?? ""}`,
})
} catch {
console.error("[ErrorBoundary]", error, info)
}
}
private handleReload = () => this.setState({ error: null })
render() {
if (!this.state.error) return this.props.children
return (
<Stack
align="center"
className="px-6 py-10 max-w-md mx-auto text-center"
gap="md"
>
<div className="text-3xl text-error"></div>
<Stack gap="xs">
<div className="text-sm font-medium text-text-primary">
Something went wrong
</div>
<div className="text-xs text-text-muted break-words font-mono">
{this.state.error.message}
</div>
</Stack>
<Button onClick={this.handleReload} size="sm" variant="secondary">
Try again
</Button>
</Stack>
)
}
}

View file

@ -0,0 +1,17 @@
import { Badge } from "../design/ui"
interface Props {
permission: string
}
export function PermissionBadge({ permission }: Props) {
const isWrite = permission === "write"
return (
<Badge
className="mt-2 self-start uppercase"
variant={isWrite ? "accent" : "neutral"}
>
{isWrite ? "read/write" : "read only"}
</Badge>
)
}

View file

@ -0,0 +1,3 @@
export function Spinner() {
return <div className="spinner" />
}

View file

@ -0,0 +1,66 @@
import type { ContainerTag, ContainerTagAccess } from "../../shared/types"
import { Card, Stack } from "../design/ui"
import { Check, Package } from "../lib/icons"
import { PermissionBadge } from "./PermissionBadge"
interface Props {
containerTag: ContainerTag
active: boolean
access?: ContainerTagAccess
onClick: (containerTag: string) => void
}
export function WorkspaceCard({
containerTag,
active,
access,
onClick,
}: Props) {
const name = containerTag.name || containerTag.containerTag
return (
<Card
as="button"
className="w-full"
onClick={() => onClick(containerTag.containerTag)}
variant={active ? "active" : "interactive"}
>
<Stack gap="sm">
<Stack align="center" direction="row" gap="sm" justify="between">
<span className="flex min-w-0 items-center gap-(--space-2)">
{containerTag.emoji ? (
<span aria-hidden className="text-base leading-none">
{containerTag.emoji}
</span>
) : (
<Package className="size-4 shrink-0 text-text-secondary" />
)}
<span
className="truncate text-(length:--text-sm) font-semibold text-text-primary"
style={{ fontFamily: "var(--font-brand)" }}
>
{name}
</span>
</span>
{active ? <Check className="size-4 shrink-0 text-accent" /> : null}
</Stack>
<div className="truncate font-mono text-(length:--text-xs) text-text-muted">
{containerTag.containerTag}
</div>
{(containerTag.documentCount > 0 || containerTag.memoryCount > 0) && (
<div className="flex items-center gap-(--space-3) text-(length:--text-xs) text-text-muted">
<span>
{containerTag.documentCount} doc
{containerTag.documentCount === 1 ? "" : "s"}
</span>
<span aria-hidden>·</span>
<span>
{containerTag.memoryCount} mem
{containerTag.memoryCount === 1 ? "" : "s"}
</span>
</div>
)}
{access ? <PermissionBadge permission={access.permission} /> : null}
</Stack>
</Card>
)
}

View file

@ -0,0 +1,143 @@
@import "tailwindcss";
@import "./tokens.css";
@custom-variant dark (&:is([data-theme="dark"] *));
/* Map design tokens to Tailwind utilities. Mirrors console-v2's mapping
* so utility class names like `bg-bg-primary`, `text-text-primary`,
* `border-border-accent`, `font-brand`, `text-xs`, `shadow-md` etc.
* resolve from one source of truth.
*/
@theme inline {
--color-bg-primary: var(--bg-primary);
--color-bg-secondary: var(--bg-secondary);
--color-bg-muted: var(--bg-muted);
--color-bg-elevated: var(--bg-elevated);
--color-bg-overlay: var(--bg-overlay);
--color-border: var(--border);
--color-border-muted: var(--border-muted);
--color-border-accent: var(--border-accent);
--color-text-primary: var(--text-primary);
--color-text-secondary: var(--text-secondary);
--color-text-muted: var(--text-muted);
--color-text-inverse: var(--text-inverse);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-accent-hover: var(--accent-hover);
--color-accent-muted: var(--accent-muted);
--color-success: var(--success);
--color-success-muted: var(--success-muted);
--color-error: var(--error);
--color-error-muted: var(--error-muted);
--color-warning: var(--warning);
--color-warning-muted: var(--warning-muted);
--color-info: var(--info);
--color-info-muted: var(--info-muted);
--color-danger: var(--danger);
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--font-display: var(--font-display);
--font-brand: var(--font-brand);
--text-xs: var(--text-xs);
--text-sm: var(--text-sm);
--text-base: var(--text-base);
--text-lg: var(--text-lg);
--text-xl: var(--text-xl);
--text-2xl: var(--text-2xl);
--text-3xl: var(--text-3xl);
--leading-tight: var(--leading-tight);
--leading-normal: var(--leading-normal);
--leading-relaxed: var(--leading-relaxed);
--radius-none: 0px;
--radius-sm: var(--radius-sm);
--radius-md: var(--radius-md);
--radius-lg: var(--radius-lg);
--radius-full: var(--radius-full);
--shadow-sm: var(--shadow-sm);
--shadow-md: var(--shadow-md);
--shadow-lg: var(--shadow-lg);
}
/* Excalidraw pattern: NO heights on root containers. The MCP host owns
* the iframe height; ext-apps' App.autoResize observes documentElement
* and reports content size back so the host right-sizes the iframe.
* Setting any height (vh, %, etc.) here breaks that feedback loop.
*
* The reset lives INSIDE @layer base so Tailwind's @layer utilities (which
* come after base) win an unlayered universal padding:0 reset would override
* every padding utility in the widget. box-sizing/margin are belt-and-braces
* on top of Tailwind preflight. */
@layer base {
* {
margin: 0;
padding: 0;
box-sizing: border-box;
border-color: var(--border);
}
body {
background: var(--bg-primary);
color: var(--text-primary);
font-family: var(--font-sans);
line-height: var(--leading-normal);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-display);
}
}
#app {
position: relative;
}
/* Spinner — kept as global because primitives can't easily express a CSS animation */
.spinner {
width: 24px;
height: 24px;
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); }
}
/* The graph canvas needs a definite height in both modes.
*
* Inline: derive height from the (stable) width via aspect-ratio width is
* fixed by the chat column, so this breaks the autoResize feedback loop.
*
* Fullscreen: the host expands the iframe, so 100dvh = the fullscreen height.
* We stay IN FLOW (no position:fixed) taking the element out of flow
* collapses the document, autoResize shrinks the iframe, and a fixed inset:0
* box in a collapsed iframe ends up with zero height (and never recovers on
* minimize). Keeping it in flow with an explicit height avoids that entirely. */
.graph-view {
width: 100%;
aspect-ratio: 4 / 3;
/* Never collapse to 0 during a resize/transition a 0-size frame makes the
* package drop its "already fitted" flag and re-fit (a camera animation that
* reads as a re-layout). The floor keeps the size continuous. */
min-height: 280px;
position: relative;
background: var(--bg-primary);
}
.graph-view.fullscreen {
aspect-ratio: auto;
height: 100dvh;
}

View file

@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View file

@ -0,0 +1,222 @@
/* Design tokens for Enterprise MCP widget.
*
* Mirrors console-v2's tokens 1:1 so brand identity is consistent across
* surfaces. Diverge only when the widget's iframed context demands it.
*
* Dark mode: MCP host applies `data-theme="dark"` on <body> via
* `applyDocumentTheme`. Tailwind's `dark:` variant is wired in globals.css
* via `@custom-variant dark (&:is([data-theme="dark"] *))`.
*/
:root {
color-scheme: light;
/* Accent gradient (Supermemory brand) */
--accent-start: #1148f7;
--accent-mid: #117dff;
--accent-end: #cdf4ff;
--accent-gradient: linear-gradient(
135deg,
var(--accent-start),
var(--accent-mid),
var(--accent-end)
);
/* Accent solid */
--accent: #117dff;
--accent-foreground: #ffffff;
--accent-hover: #1148f7;
--accent-muted: #cdf4ff;
/* Backgrounds (warm subtle) */
--bg-primary: #faf9f4;
--bg-secondary: #f3f1ec;
--bg-muted: #e8e5e0;
--bg-elevated: #ffffff;
--bg-overlay: rgba(250, 249, 244, 0.7);
/* Borders */
--border: #e3e0db;
--border-muted: #f0ede8;
--border-accent: var(--accent);
/* Text */
--text-primary: #0a0a0a;
--text-secondary: #525252;
--text-muted: #a3a3a3;
--text-inverse: #ffffff;
/* Status colors (soft pastel) */
--success: #4ade80;
--success-muted: rgba(74, 222, 128, 0.18);
--error: #f87171;
--error-muted: rgba(248, 113, 113, 0.18);
--warning: #fbbf24;
--warning-muted: rgba(251, 191, 36, 0.18);
--info: #60a5fa;
--info-muted: rgba(96, 165, 250, 0.18);
/* Action colors */
--danger: #ef4444;
/* Spacing scale (4px base) */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
--space-16: 64px;
/* Typography */
--font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, sans-serif;
--font-display:
"Space Grotesk", "Inter", ui-sans-serif, system-ui, sans-serif;
--font-brand: "Space Grotesk", "Inter", ui-sans-serif, system-ui, sans-serif;
--font-mono:
ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
--text-xs: 12px;
--text-sm: 14px;
--text-base: 16px;
--text-lg: 18px;
--text-xl: 20px;
--text-2xl: 24px;
--text-3xl: 30px;
--font-normal: 400;
--font-medium: 500;
--font-semibold: 600;
--font-bold: 700;
--leading-tight: 1.25;
--leading-normal: 1.5;
--leading-relaxed: 1.75;
/* Unified component heights */
--height-xs: 28px;
--height-sm: 32px;
--height-md: 36px;
--height-lg: 44px;
/* Icon sizes */
--icon-xs: 14px;
--icon-sm: 16px;
--icon-md: 20px;
--icon-lg: 24px;
/* Radius */
--radius-sm: 3px;
--radius-md: 4px;
--radius-lg: 6px;
--radius-full: 9999px;
/* Border width */
--border-width: 1px;
/* Page layout (mirrors console-v2 widget iframes are narrow so we
* use the mobile values throughout: 16px horizontal, 16-24px vertical). */
--page-header-px: var(--space-4);
--page-header-py: var(--space-4);
--page-title-size: var(--text-lg);
--page-title-weight: var(--font-semibold);
--page-title-mt: var(--space-5);
/* Shadows */
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md:
0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
--shadow-lg:
0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
/* Host-provided iframe dimensions; fallback for standalone dev. */
--host-height: 100vh;
--host-width: 100vw;
/* Memory graph canvas read by @supermemory/memory-graph's useGraphTheme.
* Mirrors console-v2 so the widget graph matches the console exactly. */
--graph-bg: var(--bg-secondary);
--graph-doc-fill: #e8e5e0;
--graph-doc-stroke: #d4d0cb;
--graph-doc-inner: #f3f1ec;
--graph-mem-fill: #dbeafe;
--graph-mem-fill-hover: #bfdbfe;
--graph-mem-stroke: #3b82f6;
--graph-accent: var(--accent);
--graph-text-primary: var(--text-primary);
--graph-text-secondary: var(--text-secondary);
--graph-text-muted: var(--text-muted);
/* Edge / legend colors bright + "hot" so derives/updates/extends read
* clearly against the light canvas (dark mode keeps its own hot set below). */
--graph-edge-derives: #f59e0b;
--graph-edge-updates: #8b5cf6;
--graph-edge-extends: #0ea5e9;
--graph-mem-border-forgotten: #dc2626;
--graph-mem-border-expiring: #d97706;
--graph-mem-border-recent: #059669;
--graph-glow: #3b82f6;
--graph-icon: #6b7280;
--graph-popover-bg: var(--bg-elevated);
--graph-popover-border: var(--border);
--graph-popover-text-primary: var(--text-primary);
--graph-popover-text-secondary: var(--text-secondary);
--graph-popover-text-muted: var(--text-muted);
--graph-control-bg: var(--bg-elevated);
--graph-control-border: var(--border);
}
[data-theme="dark"] {
color-scheme: dark;
--bg-primary: #0a0a0a;
--bg-secondary: #171717;
--bg-muted: #262626;
--bg-elevated: #1c1c1c;
--bg-overlay: rgba(10, 10, 10, 0.7);
--border: #2e2e2e;
--border-muted: #1f1f1f;
--text-primary: #fafafa;
--text-secondary: #a3a3a3;
--text-muted: #525252;
--text-inverse: #0a0a0a;
--success-muted: rgba(74, 222, 128, 0.2);
--error-muted: rgba(248, 113, 113, 0.2);
--warning-muted: rgba(251, 191, 36, 0.2);
--info-muted: rgba(96, 165, 250, 0.2);
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
--shadow-md:
0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -2px rgba(0, 0, 0, 0.3);
--shadow-lg:
0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -4px rgba(0, 0, 0, 0.4);
/* Memory graph canvas — dark (mirrors console-v2) */
--graph-bg: #0f1419;
--graph-doc-fill: #1b1f24;
--graph-doc-stroke: #2a2f36;
--graph-doc-inner: #13161a;
--graph-mem-fill: #0d2034;
--graph-mem-fill-hover: #112840;
--graph-mem-stroke: #3b73b8;
--graph-edge-derives: #fbbf24;
--graph-edge-updates: #a78bfa;
--graph-edge-extends: #38bdf8;
--graph-mem-border-forgotten: #ef4444;
--graph-mem-border-expiring: #f59e0b;
--graph-mem-border-recent: #10b981;
--graph-glow: #3b73b8;
--graph-icon: #3b73b8;
--graph-popover-bg: #0c1829;
--graph-popover-border: #1a2333;
--graph-popover-text-primary: #ffffff;
--graph-popover-text-secondary: #e2e8f0;
--graph-popover-text-muted: #94a3b8;
--graph-control-bg: #0c1829;
--graph-control-border: #1a2333;
}

View file

@ -0,0 +1,19 @@
import type { ReactNode } from "react"
import { cn } from "../lib/cn"
// Simplified ActionGroup for widget: no resize observer / popover collapse.
// Widget iframes are always narrow and we never overflow more than a couple
// of buttons. Just a horizontal flex row matching console-v2's inline path.
export function ActionGroup({
children,
className,
}: {
children: ReactNode
className?: string
}) {
return (
<div className={cn("flex items-center gap-(--space-3)", className)}>
{children}
</div>
)
}

View file

@ -0,0 +1,43 @@
import { cva, type VariantProps } from "class-variance-authority"
import type { HTMLAttributes } from "react"
import { cn } from "../lib/cn"
// Mirrors console-v2's Badge: mono-font, soft pastel surfaces.
const badgeVariants = cva(
[
"inline-flex items-center px-[var(--space-2)] py-0.5",
"text-[length:var(--text-xs)] font-medium font-[family-name:var(--font-mono)]",
"rounded-[var(--radius-sm)]",
].join(" "),
{
variants: {
variant: {
success: "bg-[var(--success-muted)] text-[var(--success)]",
error: "bg-[var(--error-muted)] text-[var(--error)]",
warning: "bg-[var(--warning-muted)] text-[var(--warning)]",
info: "bg-[var(--info-muted)] text-[var(--info)]",
neutral: "bg-[var(--bg-muted)] text-[var(--text-secondary)]",
accent: "bg-[var(--accent-muted)] text-[var(--accent)]",
},
},
defaultVariants: {
variant: "neutral",
},
},
)
export interface BadgeProps
extends HTMLAttributes<HTMLSpanElement>,
VariantProps<typeof badgeVariants> {}
export function Badge({ className, variant, ...props }: BadgeProps) {
return (
<span
className={cn(badgeVariants({ variant, className }))}
data-slot="badge"
{...props}
/>
)
}
export { badgeVariants }

View file

@ -0,0 +1,114 @@
import { cva, type VariantProps } from "class-variance-authority"
import { type ButtonHTMLAttributes, forwardRef, type ReactNode } from "react"
import { Loader2 } from "../../lib/icons"
import { cn } from "../lib/cn"
// Mirrors console-v2's button: uppercase + tracked, brand-font by default,
// CVA primary/secondary/ghost/danger × sm/icon. We skip `asChild` because the
// widget has no router links and dropping it saves a @radix-ui/react-slot dep.
const buttonVariants = cva(
[
"inline-flex items-center justify-center gap-2",
"uppercase tracking-[0.075em]",
"rounded-[var(--radius-md)]",
"transition-colors cursor-pointer",
"disabled:pointer-events-none disabled:opacity-50",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)]/20 focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-primary)]",
"[&_svg:not([class*='size-'])]:size-4 shrink-0",
].join(" "),
{
variants: {
variant: {
primary: [
"bg-[var(--accent)] text-[var(--accent-foreground)]",
"hover:bg-[var(--accent)]/90",
].join(" "),
secondary: [
"bg-transparent text-[var(--text-primary)]",
"border border-[var(--border)]",
"hover:bg-[var(--bg-muted)]",
].join(" "),
ghost: [
"text-[var(--text-primary)]",
"hover:bg-[var(--bg-muted)]",
].join(" "),
danger: [
"bg-[var(--danger)] text-[var(--text-inverse)]",
"hover:bg-[var(--danger)]/90",
].join(" "),
},
size: {
sm: "h-[var(--height-sm)] px-[var(--space-4)] text-[length:var(--text-xs)]",
icon: "size-[var(--height-sm)] p-0",
},
},
defaultVariants: {
variant: "primary",
size: "sm",
},
},
)
export interface ButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
shortcut?: ReactNode
brandFont?: boolean
iconLeft?: ReactNode
iconRight?: ReactNode
loading?: boolean
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(
{
className,
variant,
size,
shortcut,
brandFont = true,
iconLeft,
iconRight,
loading = false,
disabled,
children,
...props
},
ref,
) => {
const isDisabled = disabled || loading
return (
<button
className={cn(buttonVariants({ variant, size, className }))}
data-slot="button"
disabled={isDisabled}
ref={ref}
style={brandFont ? { fontFamily: "var(--font-brand)" } : undefined}
type="button"
{...props}
>
{loading ? <Loader2 className="size-4 animate-spin" /> : iconLeft}
{children}
{shortcut && !loading ? (
<span
className={cn(
"text-[0.8em] tracking-[0.1em]",
variant === "primary" || variant === "danger"
? "opacity-60"
: variant === "ghost"
? "text-[var(--text-muted)] bg-[var(--bg-muted)] rounded-[var(--radius-sm)] px-1"
: "text-[var(--text-muted)]",
)}
>
{shortcut}
</span>
) : null}
{!loading && iconRight}
</button>
)
},
)
Button.displayName = "Button"
export { buttonVariants }

View file

@ -0,0 +1,56 @@
import { cva, type VariantProps } from "class-variance-authority"
import {
type ButtonHTMLAttributes,
forwardRef,
type HTMLAttributes,
} from "react"
import { cn } from "../lib/cn"
const cardStyles = cva(
"text-left bg-bg-elevated border border-border rounded-lg p-4 transition-colors duration-150",
{
variants: {
variant: {
default: "",
interactive:
"cursor-pointer hover:bg-bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/20 focus-visible:ring-offset-2 focus-visible:ring-offset-bg-primary",
active: "cursor-pointer border-accent bg-accent-muted",
},
},
defaultVariants: { variant: "default" },
},
)
type CardVariantProps = VariantProps<typeof cardStyles>
type DivProps = HTMLAttributes<HTMLDivElement> &
CardVariantProps & { as?: "div" }
type ButtonElementProps = ButtonHTMLAttributes<HTMLButtonElement> &
CardVariantProps & { as: "button" }
export type CardProps = DivProps | ButtonElementProps
export const Card = forwardRef<HTMLElement, CardProps>(
({ className, variant, as = "div", ...props }, ref) => {
const cls = cn(cardStyles({ variant }), className)
if (as === "button") {
return (
<button
className={cls}
ref={ref as React.Ref<HTMLButtonElement>}
type="button"
{...(props as ButtonHTMLAttributes<HTMLButtonElement>)}
/>
)
}
return (
<div
className={cls}
ref={ref as React.Ref<HTMLDivElement>}
{...(props as HTMLAttributes<HTMLDivElement>)}
/>
)
},
)
Card.displayName = "Card"

View file

@ -0,0 +1,35 @@
import { cva, type VariantProps } from "class-variance-authority"
import { type ButtonHTMLAttributes, forwardRef } from "react"
import { cn } from "../lib/cn"
const chipStyles = cva(
"inline-flex items-center px-[var(--space-3)] h-[var(--height-xs)] rounded-full text-[length:var(--text-xs)] font-medium border transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/20 focus-visible:ring-offset-2 focus-visible:ring-offset-bg-primary",
{
variants: {
selected: {
true: "bg-accent-muted border-accent text-accent",
false:
"bg-bg-elevated border-border text-text-secondary hover:border-border-accent hover:bg-bg-muted",
},
},
defaultVariants: { selected: false },
},
)
export interface ChipProps
extends ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof chipStyles> {}
export const Chip = forwardRef<HTMLButtonElement, ChipProps>(
({ className, selected, children, ...props }, ref) => (
<button
className={cn(chipStyles({ selected }), className)}
ref={ref}
type="button"
{...props}
>
{children}
</button>
),
)
Chip.displayName = "Chip"

View file

@ -0,0 +1,29 @@
import type { ReactNode } from "react"
import { cn } from "../lib/cn"
interface Props {
label?: ReactNode
hint?: ReactNode
error?: string | null
className?: string
children: ReactNode
}
export function Field({ label, hint, error, className, children }: Props) {
return (
<div className={cn("flex flex-col gap-1.5", className)}>
{label ? (
// biome-ignore lint/a11y/noLabelWithoutControl: generic field wrapper where children provide the input
<label className="text-xs font-medium text-text-secondary uppercase tracking-wide">
{label}
</label>
) : null}
{children}
{error ? (
<span className="text-xs text-error">{error}</span>
) : hint ? (
<span className="text-xs text-text-muted">{hint}</span>
) : null}
</div>
)
}

View file

@ -0,0 +1,99 @@
import type { ChangeEvent, DragEvent, ReactNode } from "react"
import { useCallback, useState } from "react"
import { Upload } from "../../lib/icons"
import { cn } from "../lib/cn"
// Mirrors console-v2's FileUpload: label-based drop area, two-line copy,
// muted icon, --space-12 vertical padding, accent ring on drag-over.
//
// Simpler than console-v2's because the widget uses a single accept string
// and a single-file flow today (multiple={false}). MIME/extension partition
// is left to the caller via the existing `accept` HTML attribute.
interface FileUploadProps {
accept: string
onFile: (file: File) => void
title?: string
description?: string
icon?: ReactNode
disabled?: boolean
className?: string
}
export function FileUpload({
accept,
onFile,
title = "Drop a file here or click to browse",
description,
icon,
disabled,
className,
}: FileUploadProps) {
const [dragOver, setDragOver] = useState(false)
const handleDrop = useCallback(
(e: DragEvent<HTMLLabelElement>) => {
e.preventDefault()
if (disabled) return
setDragOver(false)
const file = e.dataTransfer.files[0]
if (file) onFile(file)
},
[disabled, onFile],
)
const handleInput = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
if (disabled) return
const file = e.target.files?.[0]
if (file) onFile(file)
e.target.value = ""
},
[disabled, onFile],
)
return (
<label
className={cn(
"flex min-h-[180px] flex-col items-center justify-center gap-(--space-3)",
"py-(--space-12) px-(--space-6)",
"border-2 border-dashed rounded-(--radius-lg)",
"cursor-pointer transition-colors",
dragOver
? "border-accent bg-accent-muted/30"
: "border-border hover:bg-bg-muted/40",
disabled && "pointer-events-none opacity-50",
className,
)}
onDragEnter={(e) => {
e.preventDefault()
if (!disabled) setDragOver(true)
}}
onDragLeave={(e) => {
e.preventDefault()
setDragOver(false)
}}
onDragOver={(e) => e.preventDefault()}
onDrop={handleDrop}
>
{icon ?? <Upload className="size-8 text-text-muted" />}
<div className="flex flex-col gap-(--space-1) text-center">
<p className="text-(length:--text-sm) font-medium text-text-primary">
{title}
</p>
{description ? (
<p className="text-(length:--text-xs) text-text-muted">
{description}
</p>
) : null}
</div>
<input
accept={accept}
className="hidden"
disabled={disabled}
onChange={handleInput}
type="file"
/>
</label>
)
}

View file

@ -0,0 +1,57 @@
import { cva, type VariantProps } from "class-variance-authority"
import { forwardRef, type InputHTMLAttributes } from "react"
import { cn } from "../lib/cn"
// Mirrors console-v2's Input: transparent surface, soft hover tint,
// focus shadow, aria-invalid styling. Sizes sm/md/lg match component heights.
const inputVariants = cva(
[
"flex w-full",
"bg-transparent text-[var(--text-primary)]",
"border border-[var(--border)]",
"rounded-[var(--radius-lg)]",
"placeholder:text-[var(--text-muted)]",
"transition-colors",
"hover:bg-[var(--text-muted)]/10",
"focus-visible:outline-none focus-visible:shadow-[0_0_0_1px_var(--border)]",
"disabled:cursor-not-allowed disabled:opacity-50",
"aria-invalid:border-[var(--error)] aria-invalid:ring-[var(--error)]",
"file:border-0 file:bg-transparent file:text-[length:var(--text-sm)] file:font-medium",
].join(" "),
{
variants: {
inputSize: {
sm: "h-[var(--height-sm)] px-[var(--space-3)] text-[length:var(--text-xs)]",
md: "h-[var(--height-md)] px-[var(--space-4)] text-[length:var(--text-sm)]",
lg: "h-[var(--height-lg)] px-[var(--space-4)] text-[length:var(--text-base)]",
},
},
defaultVariants: {
inputSize: "md",
},
},
)
export interface InputProps
extends Omit<InputHTMLAttributes<HTMLInputElement>, "size">,
VariantProps<typeof inputVariants> {
size?: "sm" | "md" | "lg"
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ className, size, inputSize, type, ...props }, ref) => {
const resolvedSize = size ?? inputSize ?? "md"
return (
<input
className={cn(inputVariants({ inputSize: resolvedSize, className }))}
data-slot="input"
ref={ref}
type={type}
{...props}
/>
)
},
)
Input.displayName = "Input"
export { inputVariants }

View file

@ -0,0 +1,53 @@
import { memo, type ReactNode } from "react"
import { cn } from "../lib/cn"
interface PageHeaderProps {
title: string
description?: string
actions?: ReactNode
children?: ReactNode
className?: string
}
// Mirrors console-v2's PageHeader: brand-font title at `--page-title-size`,
// optional description below, right-aligned actions, consistent horizontal
// page padding so body content can align with `px-(--page-header-px)`.
export const PageHeader = memo(function PageHeader({
title,
description,
actions,
children,
className,
}: PageHeaderProps) {
return (
<div
className={cn(
"px-(--page-header-px) pt-(--page-title-mt) pb-(--page-header-py)",
className,
)}
data-slot="page-header"
>
<div className="flex items-center justify-between gap-(--space-4)">
<div className="flex flex-col gap-(--space-1) min-w-0">
<h1
className="text-(length:--page-title-size) font-(--page-title-weight) text-text-primary truncate"
style={{ fontFamily: "var(--font-brand)" }}
>
{title}
</h1>
{description ? (
<p className="text-(length:--text-sm) text-text-secondary">
{description}
</p>
) : null}
</div>
{actions ? (
<div className="flex items-center gap-(--space-3) shrink-0">
{actions}
</div>
) : null}
</div>
{children}
</div>
)
})

View file

@ -0,0 +1,35 @@
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { type ComponentPropsWithoutRef, forwardRef } from "react"
import { cn } from "../lib/cn"
// Ported from console-v2 shared/popover.tsx — same surface treatment.
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverAnchor = PopoverPrimitive.Anchor
const PopoverContent = forwardRef<
HTMLDivElement,
ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
align={align}
className={cn(
"z-50 w-72 p-(--space-4)",
"bg-bg-elevated border border-border",
"rounded-(--radius-md)",
"shadow-md",
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
className,
)}
data-slot="popover-content"
ref={ref}
sideOffset={sideOffset}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = "PopoverContent"
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View file

@ -0,0 +1,50 @@
import { cva, type VariantProps } from "class-variance-authority"
import { forwardRef, type HTMLAttributes } from "react"
import { cn } from "../lib/cn"
const stackStyles = cva("flex", {
variants: {
direction: {
row: "flex-row",
column: "flex-col",
rowWrap: "flex-row flex-wrap",
},
gap: {
none: "gap-0",
xs: "gap-1",
sm: "gap-2",
md: "gap-3",
lg: "gap-4",
xl: "gap-6",
},
align: {
start: "items-start",
center: "items-center",
end: "items-end",
stretch: "items-stretch",
},
justify: {
start: "justify-start",
center: "justify-center",
end: "justify-end",
between: "justify-between",
around: "justify-around",
},
},
defaultVariants: { direction: "column", gap: "md", align: "stretch" },
})
export interface StackProps
extends HTMLAttributes<HTMLDivElement>,
VariantProps<typeof stackStyles> {}
export const Stack = forwardRef<HTMLDivElement, StackProps>(
({ className, direction, gap, align, justify, ...props }, ref) => (
<div
className={cn(stackStyles({ direction, gap, align, justify }), className)}
ref={ref}
{...props}
/>
),
)
Stack.displayName = "Stack"

View file

@ -0,0 +1,35 @@
import { forwardRef, type TextareaHTMLAttributes } from "react"
import { cn } from "../lib/cn"
// Multi-line counterpart to Input. Same surface treatment (transparent,
// soft hover, focus shadow, aria-invalid). No height variants — caller
// controls min-height via className.
const textAreaClass = [
"flex w-full",
"bg-transparent text-[var(--text-primary)]",
"border border-[var(--border)]",
"rounded-[var(--radius-lg)]",
"px-[var(--space-3)] py-[var(--space-2)]",
"text-[length:var(--text-sm)] leading-normal font-sans",
"placeholder:text-[var(--text-muted)]",
"transition-colors resize-y",
"hover:bg-[var(--text-muted)]/10",
"focus-visible:outline-none focus-visible:shadow-[0_0_0_1px_var(--border)]",
"disabled:cursor-not-allowed disabled:opacity-50",
"aria-invalid:border-[var(--error)] aria-invalid:ring-[var(--error)]",
].join(" ")
export interface TextAreaProps
extends TextareaHTMLAttributes<HTMLTextAreaElement> {}
export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(
({ className, ...props }, ref) => (
<textarea
className={cn(textAreaClass, className)}
data-slot="textarea"
ref={ref}
{...props}
/>
),
)
TextArea.displayName = "TextArea"

View file

@ -0,0 +1,46 @@
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { type ComponentPropsWithoutRef, forwardRef } from "react"
import { cn } from "../lib/cn"
// Ported from console-v2 shared/tooltip.tsx.
const TooltipProvider = TooltipPrimitive.Provider
function Tooltip({
delayDuration = 300,
...props
}: ComponentPropsWithoutRef<typeof TooltipPrimitive.Root> & {
delayDuration?: number
}) {
return (
<TooltipProvider delayDuration={delayDuration}>
<TooltipPrimitive.Root {...props} />
</TooltipProvider>
)
}
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = forwardRef<
HTMLDivElement,
ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
className={cn(
"z-50 px-(--space-2) py-(--space-1)",
"text-(length:--text-xs)",
"bg-text-primary text-bg-elevated",
"rounded-(--radius-md)",
"shadow-md",
className,
)}
data-slot="tooltip-content"
ref={ref}
sideOffset={sideOffset}
{...props}
/>
</TooltipPrimitive.Portal>
))
TooltipContent.displayName = "TooltipContent"
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }

View file

@ -0,0 +1,158 @@
import { type ReactNode, useEffect, useMemo, useState } from "react"
import { Check, ChevronDown, Package, Search } from "../../lib/icons"
import { cn } from "../lib/cn"
import { Button } from "./Button"
import { Popover, PopoverContent, PopoverTrigger } from "./Popover"
import { Tooltip, TooltipContent, TooltipTrigger } from "./Tooltip"
export interface WorkspaceOption {
value: string
label: string
description?: string
icon?: ReactNode
}
interface WorkspaceSelectProps {
value: string | null
onValueChange: (value: string) => void
options: WorkspaceOption[]
placeholder?: string
emptyText?: string
searchPlaceholder?: string
disabled?: boolean
className?: string
}
// Ports console-v2's ChipSelect/ContainerTagSelect: a Popover-anchored,
// searchable list. Scales to hundreds of workspaces where chips can't.
export function WorkspaceSelect({
value,
onValueChange,
options,
placeholder = "Select workspace",
emptyText = "No workspaces",
searchPlaceholder = "Search workspaces…",
disabled = false,
className,
}: WorkspaceSelectProps) {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState("")
const selected = value ? options.find((o) => o.value === value) : null
const triggerLabel = selected?.label ?? placeholder
useEffect(() => {
if (!open) setQuery("")
}, [open])
const filtered = useMemo(() => {
if (!query.trim()) return options
const q = query.trim().toLowerCase()
return options.filter(
(o) =>
o.label.toLowerCase().includes(q) ||
o.value.toLowerCase().includes(q) ||
(o.description?.toLowerCase().includes(q) ?? false),
)
}, [options, query])
return (
<Tooltip>
<Popover onOpenChange={setOpen} open={open}>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
brandFont={false}
className={cn(
"w-full justify-between normal-case tracking-normal",
"focus-visible:ring-0 focus-visible:ring-offset-0",
"data-[state=open]:bg-bg-muted",
className,
)}
disabled={disabled}
iconLeft={<Package className="size-4 text-text-secondary" />}
iconRight={<ChevronDown className="size-3 text-text-muted" />}
size="sm"
type="button"
variant="secondary"
>
<span
className={cn(
"flex-1 truncate text-left",
!selected && "text-text-muted",
)}
>
{triggerLabel}
</span>
</Button>
</PopoverTrigger>
</TooltipTrigger>
<PopoverContent
align="start"
className="w-(--radix-popover-trigger-width) min-w-[260px] p-(--space-1)"
>
<div className="flex items-center gap-(--space-2) border-b border-border-muted px-(--space-2) py-(--space-2) mb-(--space-1)">
<Search className="size-4 text-text-muted shrink-0" />
<input
className="w-full bg-transparent text-(length:--text-sm) text-text-primary placeholder:text-text-muted focus:outline-none"
onChange={(e) => setQuery(e.target.value)}
placeholder={searchPlaceholder}
value={query}
/>
</div>
<div className="flex max-h-[300px] flex-col overflow-y-auto">
{filtered.length === 0 ? (
<div className="px-(--space-3) py-(--space-3) text-(length:--text-xs) text-text-muted italic">
{query ? "No matches" : emptyText}
</div>
) : (
filtered.map((option) => {
const isSelected = option.value === value
return (
<button
className={cn(
"flex items-start justify-between gap-(--space-2)",
"px-(--space-3) py-(--space-2)",
"rounded-(--radius-md)",
"text-left cursor-pointer",
"hover:bg-bg-muted transition-colors",
"focus-visible:outline-none focus-visible:bg-bg-muted",
)}
key={option.value}
onClick={() => {
onValueChange(option.value)
setOpen(false)
}}
type="button"
>
<span className="flex min-w-0 flex-col gap-0.5">
<span className="flex items-center gap-(--space-2)">
{option.icon ?? (
<Package className="size-3.5 text-text-muted shrink-0" />
)}
<span className="text-(length:--text-sm) text-text-primary truncate">
{option.label}
</span>
</span>
{option.description ? (
<span className="text-(length:--text-xs) text-text-muted truncate">
{option.description}
</span>
) : null}
</span>
{isSelected ? (
<Check className="size-3.5 text-accent shrink-0 mt-0.5" />
) : null}
</button>
)
})
)}
</div>
</PopoverContent>
</Popover>
{selected ? (
<TooltipContent side="top">{selected.value}</TooltipContent>
) : null}
</Tooltip>
)
}

View file

@ -0,0 +1,24 @@
export { ActionGroup } from "./ActionGroup"
export { Badge, type BadgeProps } from "./Badge"
export { Button, type ButtonProps } from "./Button"
export { Card, type CardProps } from "./Card"
export { Chip, type ChipProps } from "./Chip"
export { Field } from "./Field"
export { FileUpload } from "./FileUpload"
export { Input, type InputProps } from "./Input"
export { PageHeader } from "./PageHeader"
export {
Popover,
PopoverAnchor,
PopoverContent,
PopoverTrigger,
} from "./Popover"
export { Stack, type StackProps } from "./Stack"
export { TextArea, type TextAreaProps } from "./TextArea"
export {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "./Tooltip"
export { type WorkspaceOption, WorkspaceSelect } from "./WorkspaceSelect"

View file

@ -0,0 +1,82 @@
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"
import { useMemo } from "react"
import type { ViewMessage } from "../../shared/types"
import { app } from "../lib/app"
export interface ToolCallResult<T = unknown> {
ok: boolean
error?: string
data?: T
}
/**
* Single entry point for all `app.*` SDK calls.
*
* Why this exists:
* The previous codebase called `app.callTool(...)` which doesn't exist on the
* App class the correct method is `app.callServerTool(...)`. The bug compiled
* silently and broke 3 user flows. Routing every call through this hook makes
* that class of mistake impossible: there is no `callTool` exposed.
*/
export function useApp() {
return useMemo(() => {
return {
/** Call an MCP server tool and await the result. */
async callTool<T = ViewMessage>(
name: string,
args: Record<string, unknown>,
): Promise<ToolCallResult<T>> {
try {
const result = (await app.callServerTool({
name,
arguments: args,
})) as CallToolResult & { structuredContent?: unknown }
if (result.isError) {
const text =
result.content?.[0]?.type === "text"
? result.content[0].text
: "Tool returned an error"
return { ok: false, error: text }
}
return {
ok: true,
data: result.structuredContent as T,
}
} catch (err) {
return { ok: false, error: String(err) }
}
},
/** Inject a user message into the conversation. Triggers a new agent turn. */
sendMessage(content: string) {
return app.sendMessage({
role: "user",
content: [{ type: "text", text: content }],
})
},
/** Update ambient model context. Model sees on next user message. */
updateContext(text: string) {
return app.updateModelContext({
content: [{ type: "text", text }],
})
},
/** Send a structured log line to the host. */
log(level: "debug" | "info" | "warning" | "error", message: string) {
return app.sendLog({ level, data: message })
},
/** Request the host to switch display mode. */
requestDisplayMode(mode: "inline" | "fullscreen" | "pip") {
return app.requestDisplayMode({ mode })
},
getHostContext() {
return app.getHostContext()
},
}
}, [])
}
export type AppApi = ReturnType<typeof useApp>

View file

@ -0,0 +1,62 @@
import type { McpUiHostContext } from "@modelcontextprotocol/ext-apps"
import {
applyDocumentTheme,
applyHostFonts,
applyHostStyleVariables,
} from "@modelcontextprotocol/ext-apps"
import { useEffect } from "react"
import { useHostContext } from "./useHostContext"
function applyHostDimensions(ctx: McpUiHostContext) {
const dims = (
ctx as McpUiHostContext & {
containerDimensions?: { height?: number; width?: number }
}
).containerDimensions
if (dims?.height) {
document.documentElement.style.setProperty(
"--host-height",
`${dims.height}px`,
)
}
if (dims?.width) {
document.documentElement.style.setProperty(
"--host-width",
`${dims.width}px`,
)
}
}
function applySafeArea(ctx: McpUiHostContext) {
const insets = (
ctx as McpUiHostContext & {
safeAreaInsets?: {
top: number
right: number
bottom: number
left: number
}
}
).safeAreaInsets
if (insets) {
const { top, right, bottom, left } = insets
document.body.style.padding = `${top}px ${right}px ${bottom}px ${left}px`
}
}
/**
* Applies host-provided theme tokens, CSS variables, fonts, dimensions,
* and safe-area insets to the document. Re-applies on every host context change.
*/
export function useApplyHostTheme() {
const ctx = useHostContext()
useEffect(() => {
if (!ctx) return
if (ctx.theme) applyDocumentTheme(ctx.theme)
if (ctx.styles?.variables) applyHostStyleVariables(ctx.styles.variables)
if (ctx.styles?.css?.fonts) applyHostFonts(ctx.styles.css.fonts)
applyHostDimensions(ctx)
applySafeArea(ctx)
}, [ctx])
}

View file

@ -0,0 +1,25 @@
import type { McpUiHostContext } from "@modelcontextprotocol/ext-apps"
import { useEffect, useState } from "react"
import { app } from "../lib/app"
/**
* Subscribes to host context changes (theme, dimensions, displayMode, fonts).
* Returns the latest context, or null until the connection is established.
*/
export function useHostContext(): McpUiHostContext | null {
const [ctx, setCtx] = useState<McpUiHostContext | null>(
() => app.getHostContext() ?? null,
)
useEffect(() => {
const handler = (next: McpUiHostContext) => setCtx(next)
app.onhostcontextchanged = handler
return () => {
if (app.onhostcontextchanged === handler) {
app.onhostcontextchanged = undefined
}
}
}, [])
return ctx
}

View file

@ -0,0 +1,18 @@
import { useCallback } from "react"
import { useApp } from "./useApp"
/**
* Convenience wrapper around `app.sendLog` that swallows transport errors
* widgets shouldn't crash because the host couldn't accept a log message.
*/
export function useLog() {
const { log } = useApp()
return useCallback(
(level: "debug" | "info" | "warning" | "error", message: string) => {
log(level, message).catch(() => {
/* host may not support logging — ignore */
})
},
[log],
)
}

View file

@ -0,0 +1,91 @@
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"
import { useEffect, useState } from "react"
import type { ViewMessage } from "../../shared/types"
import { app } from "../lib/app"
function safeLog(
level: "debug" | "info" | "warning" | "error",
message: string,
) {
try {
void app.sendLog({ level, data: message })
} catch {
// host may not support logging — ignore
}
}
type ViewState =
| { kind: "loading" }
| { kind: "view"; message: ViewMessage }
| { kind: "error"; message: string }
| { kind: "raw"; structuredContent: unknown }
/**
* Drives the widget's top-level view state from MCP host events.
*
* - `ontoolinput` / `ontoolinputpartial`: shows loading
* - `ontoolresult`: parses `structuredContent` as `ViewMessage` and renders
* - `ontoolcancelled`: returns to loading
* - errors: surfaces via the error variant
*
* Note: this only handles MODEL-initiated tool calls. Widget-initiated calls
* via `useApp().callTool()` return the result directly to the caller; they do
* not flow through this hook.
*/
export function useViewState(): {
state: ViewState
setView: (msg: ViewMessage) => void
setError: (message: string) => void
} {
const [state, setState] = useState<ViewState>({ kind: "loading" })
useEffect(() => {
app.ontoolinput = (input: unknown) => {
const name =
typeof input === "object" && input !== null && "name" in input
? String((input as { name: unknown }).name)
: "?"
safeLog("info", `[host] ontoolinput: ${name}`)
setState({ kind: "loading" })
}
app.ontoolinputpartial = () => setState({ kind: "loading" })
app.ontoolcancelled = () => {
safeLog("info", "[host] ontoolcancelled")
setState({ kind: "loading" })
}
app.ontoolresult = (result: CallToolResult) => {
const sc = (result as { structuredContent?: unknown }).structuredContent
if (!sc || typeof sc !== "object") {
safeLog("warning", "[host] ontoolresult: no structuredContent")
setState({ kind: "raw", structuredContent: sc })
return
}
if ("view" in sc) {
const msg = sc as ViewMessage
safeLog("info", `[host] ontoolresult: view=${msg.view}`)
setState({ kind: "view", message: msg })
return
}
safeLog("warning", "[host] ontoolresult: structuredContent without view")
setState({ kind: "raw", structuredContent: sc })
}
app.onerror = (error: unknown) => {
safeLog("error", `[host] onerror: ${String(error)}`)
setState({ kind: "error", message: String(error) })
}
return () => {
app.ontoolinput = undefined
app.ontoolinputpartial = undefined
app.ontoolcancelled = undefined
app.ontoolresult = undefined
app.onerror = undefined
}
}, [])
return {
state,
setView: (msg) => setState({ kind: "view", message: msg }),
setError: (message) => setState({ kind: "error", message }),
}
}

View file

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Enterprise MCP</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Space+Grotesk:wght@500;600;700&display=swap" rel="stylesheet" />
</head>
<body>
<div id="app"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>

View file

@ -0,0 +1,7 @@
/**
* Singleton App instance one MCP App connection per widget.
* Imported only via the `useApp()` hook for typed access.
*/
import { App } from "@modelcontextprotocol/ext-apps"
export const app = new App({ name: "Enterprise MCP", version: "1.0.0" })

View file

@ -0,0 +1,48 @@
import type { Icon } from "@phosphor-icons/react"
import {
ArrowsIn as PArrowsIn,
ArrowsOut as PArrowsOut,
Brain as PBrain,
CaretDown as PCaretDown,
CaretUpDown as PCaretUpDown,
Check as PCheck,
CheckCircle as PCheckCircle,
FileText as PFileText,
MagnifyingGlass as PMagnifyingGlass,
Package as PPackage,
Plus as PPlus,
SpinnerGap as PSpinnerGap,
UploadSimple as PUploadSimple,
WarningCircle as PWarningCircle,
X as PX,
} from "@phosphor-icons/react"
import { type ComponentType, createElement } from "react"
// Mirror console-v2's icon convention: Phosphor icons rendered at `light`
// weight, exported under familiar names. Keeps the widget visually identical
// to the console.
function light(
Base: Icon,
): ComponentType<{ className?: string; size?: number | string }> {
function LightIcon(props: { className?: string; size?: number | string }) {
return createElement(Base, { ...props, weight: "light" })
}
LightIcon.displayName = Base.displayName
return LightIcon
}
export const ArrowsIn = light(PArrowsIn)
export const ArrowsOut = light(PArrowsOut)
export const Brain = light(PBrain)
export const Check = light(PCheck)
export const CheckCircle = light(PCheckCircle)
export const ChevronDown = light(PCaretDown)
export const ChevronsUpDown = light(PCaretUpDown)
export const FileText = light(PFileText)
export const Loader2 = light(PSpinnerGap)
export const Package = light(PPackage)
export const Plus = light(PPlus)
export const Search = light(PMagnifyingGlass)
export const Upload = light(PUploadSimple)
export const WarningCircle = light(PWarningCircle)
export const X = light(PX)

View file

@ -0,0 +1,15 @@
/**
* Reads a File as a base64-encoded string (without the `data:...;base64,` prefix).
*/
export function readFileAsBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
const result = reader.result as string
const comma = result.indexOf(",")
resolve(comma >= 0 ? result.slice(comma + 1) : result)
}
reader.onerror = () => reject(reader.error ?? new Error("read failed"))
reader.readAsDataURL(file)
})
}

View file

@ -0,0 +1,18 @@
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import { App as WidgetApp } from "./App"
import { ErrorBoundary } from "./ErrorBoundary"
import { app } from "./lib/app"
import "./design/globals.css"
const root = createRoot(document.getElementById("app") as HTMLElement)
root.render(
<StrictMode>
<ErrorBoundary>
<WidgetApp />
</ErrorBoundary>
</StrictMode>,
)
// Establish the postMessage channel with the MCP host.
app.connect()

View file

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Enterprise MCP · Studio</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Space+Grotesk:wght@500;600;700&display=swap" rel="stylesheet" />
</head>
<body>
<div id="studio"></div>
<script type="module" src="./studio/main.tsx"></script>
</body>
</html>

View file

@ -0,0 +1,430 @@
import { type ReactNode, useState } from "react"
import { WorkspaceCard } from "../components/WorkspaceCard"
import {
ActionGroup,
Badge,
Button,
Card,
Chip,
Field,
FileUpload,
Input,
PageHeader,
Stack,
TextArea,
WorkspaceSelect,
} from "../design/ui"
import { Confirmation } from "../views/Confirmation"
import { ErrorView } from "../views/Error"
import { Graph } from "../views/Graph"
import { Loading } from "../views/Loading"
import { Picker } from "../views/Picker"
import { Save } from "../views/Save"
import { Success } from "../views/Success"
import { Upload } from "../views/Upload"
import {
mockAssignedTags,
mockContainerTags,
mockDocuments,
mockWritableTags,
} from "./mocks"
type Theme = "light" | "dark"
type Width = "narrow" | "wide"
const NARROW = 420
const WIDE = 720
// A no-op handler set for views. In the Studio there is no MCP host, so
// widget-initiated tool calls would reject; that's fine — we only review
// layout/spacing here, and onAdvance/onError surface what *would* happen.
function useStubHandlers(label: string) {
const [event, setEvent] = useState<string | null>(null)
return {
event,
onAdvance: (msg: { view: string }) =>
setEvent(`${label}: onAdvance → ${msg.view}`),
onError: (m: string) => setEvent(`${label}: onError → ${m}`),
}
}
function Section({
title,
description,
children,
}: {
title: string
description?: string
children: ReactNode
}) {
return (
<section className="flex flex-col gap-(--space-4)">
<div className="flex flex-col gap-(--space-1)">
<h2
className="text-(length:--text-lg) font-semibold text-text-primary"
style={{ fontFamily: "var(--font-brand)" }}
>
{title}
</h2>
{description ? (
<p className="text-(length:--text-sm) text-text-secondary">
{description}
</p>
) : null}
</div>
{children}
</section>
)
}
// A device-style frame that mimics the widget iframe so spacing reads true.
function Frame({
label,
width,
children,
}: {
label: string
width: number
children: ReactNode
}) {
return (
<div className="flex flex-col gap-(--space-2)">
<span className="text-(length:--text-xs) font-mono text-text-muted">
{label}
</span>
<div
className="overflow-hidden rounded-(--radius-lg) border border-border bg-bg-primary shadow-md"
style={{ width }}
>
{children}
</div>
</div>
)
}
function Swatch({ name, varName }: { name: string; varName: string }) {
return (
<div className="flex flex-col gap-1">
<div
className="h-12 rounded-(--radius-md) border border-border"
style={{ background: `var(${varName})` }}
/>
<span className="text-(length:--text-xs) text-text-secondary">
{name}
</span>
<span className="text-[10px] font-mono text-text-muted">{varName}</span>
</div>
)
}
export function Studio() {
const [theme, setTheme] = useState<Theme>("light")
const [width, setWidth] = useState<Width>("narrow")
const frameWidth = width === "narrow" ? NARROW : WIDE
const applyTheme = (t: Theme) => {
setTheme(t)
document.documentElement.setAttribute("data-theme", t)
}
const saveStub = useStubHandlers("Save")
const uploadStub = useStubHandlers("Upload")
const pickerStub = useStubHandlers("Picker")
const [selectValue, setSelectValue] = useState<string | null>(
"sm_project_marketing",
)
return (
<div className="min-h-screen bg-bg-secondary text-text-primary">
{/* Toolbar */}
<div className="sticky top-0 z-50 flex items-center justify-between gap-(--space-4) border-b border-border bg-bg-elevated px-(--space-6) py-(--space-3)">
<span
className="text-(length:--text-base) font-semibold"
style={{ fontFamily: "var(--font-brand)" }}
>
Enterprise MCP · Studio
</span>
<div className="flex items-center gap-(--space-4)">
<div className="flex items-center gap-(--space-2)">
<span className="text-(length:--text-xs) text-text-muted">
Theme
</span>
<Chip
onClick={() => applyTheme("light")}
selected={theme === "light"}
>
Light
</Chip>
<Chip
onClick={() => applyTheme("dark")}
selected={theme === "dark"}
>
Dark
</Chip>
</div>
<div className="flex items-center gap-(--space-2)">
<span className="text-(length:--text-xs) text-text-muted">
Width
</span>
<Chip
onClick={() => setWidth("narrow")}
selected={width === "narrow"}
>
Narrow {NARROW}
</Chip>
<Chip onClick={() => setWidth("wide")} selected={width === "wide"}>
Wide {WIDE}
</Chip>
</div>
</div>
</div>
<div className="mx-auto flex max-w-5xl flex-col gap-(--space-12) px-(--space-6) py-(--space-8)">
{/* ── Primitives ───────────────────────────────────────── */}
<Section
description="Atoms from design/ui — fully interactive, no host needed."
title="Primitives"
>
<Card>
<Stack gap="lg">
<div className="flex flex-col gap-(--space-3)">
<span className="text-(length:--text-xs) uppercase tracking-wide text-text-muted">
Button variants
</span>
<div className="flex flex-wrap items-center gap-(--space-3)">
<Button variant="primary">Primary</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="danger">Danger</Button>
<Button loading variant="primary">
Loading
</Button>
<Button disabled variant="primary">
Disabled
</Button>
</div>
</div>
<div className="flex flex-col gap-(--space-3)">
<span className="text-(length:--text-xs) uppercase tracking-wide text-text-muted">
Badges
</span>
<div className="flex flex-wrap items-center gap-(--space-3)">
<Badge variant="neutral">neutral</Badge>
<Badge variant="accent">accent</Badge>
<Badge variant="success">success</Badge>
<Badge variant="error">error</Badge>
<Badge variant="warning">warning</Badge>
<Badge variant="info">info</Badge>
</div>
</div>
<div className="flex flex-col gap-(--space-3)">
<span className="text-(length:--text-xs) uppercase tracking-wide text-text-muted">
Chips
</span>
<div className="flex flex-wrap items-center gap-(--space-2)">
<Chip selected>Selected</Chip>
<Chip>Unselected</Chip>
<Chip>sm_project_marketing</Chip>
</div>
</div>
<div className="grid grid-cols-1 gap-(--space-4) sm:grid-cols-2">
<Field hint="Single-line input primitive" label="Input">
<Input placeholder="Type something…" />
</Field>
<Field error="This field has an error" label="Input (error)">
<Input aria-invalid placeholder="Invalid…" />
</Field>
</div>
<Field label="TextArea">
<TextArea className="min-h-24" placeholder="Multi-line text…" />
</Field>
</Stack>
</Card>
</Section>
{/* ── Tokens ───────────────────────────────────────────── */}
<Section
description="Color tokens resolve from tokens.css. Toggle the theme to compare."
title="Color tokens"
>
<Card>
<div className="grid grid-cols-2 gap-(--space-4) sm:grid-cols-4 md:grid-cols-6">
<Swatch name="Accent" varName="--accent" />
<Swatch name="BG primary" varName="--bg-primary" />
<Swatch name="BG secondary" varName="--bg-secondary" />
<Swatch name="BG muted" varName="--bg-muted" />
<Swatch name="BG elevated" varName="--bg-elevated" />
<Swatch name="Border" varName="--border" />
<Swatch name="Success" varName="--success" />
<Swatch name="Error" varName="--error" />
<Swatch name="Warning" varName="--warning" />
<Swatch name="Info" varName="--info" />
<Swatch name="Danger" varName="--danger" />
<Swatch name="Accent muted" varName="--accent-muted" />
</div>
</Card>
</Section>
{/* ── Composite components ─────────────────────────────── */}
<Section title="Composite components">
<div className="flex flex-wrap gap-(--space-4)">
<div className="w-64">
<WorkspaceCard
access={mockAssignedTags[0]}
active={false}
containerTag={mockContainerTags[0]}
onClick={() => {}}
/>
</div>
<div className="w-64">
<WorkspaceCard
access={mockAssignedTags[1]}
active
containerTag={mockContainerTags[1]}
onClick={() => {}}
/>
</div>
<div className="w-64">
<WorkspaceCard
active={false}
containerTag={mockContainerTags[3]}
onClick={() => {}}
/>
</div>
</div>
<Card className="max-w-md">
<PageHeader
actions={
<ActionGroup>
<Button size="sm">Action</Button>
</ActionGroup>
}
description="PageHeader with description and an action."
title="Page Header"
/>
</Card>
<div className="max-w-md">
<FileUpload
accept=".txt,.pdf"
description="Supports TXT, PDF, PNG, JPG, MP4"
onFile={() => {}}
/>
</div>
<Card className="max-w-sm">
<Field
hint="Searchable Popover — scales to hundreds of workspaces."
label="Workspace select"
>
<WorkspaceSelect
onValueChange={setSelectValue}
options={mockContainerTags.map((t) => ({
value: t.containerTag,
label: t.name,
description: t.containerTag,
}))}
value={selectValue}
/>
</Field>
</Card>
</Section>
{/* ── Views ────────────────────────────────────────────── */}
<Section
description="Full views with mock data, rendered at the selected width. Click events show below each frame (no host, so tool calls won't fire)."
title="Views"
>
<div className="flex flex-wrap gap-(--space-8)">
<Frame label="Picker" width={frameWidth}>
<Picker
activeTag="sm_project_marketing"
assignedTags={mockAssignedTags}
containerTags={mockContainerTags}
onAdvance={pickerStub.onAdvance}
onError={pickerStub.onError}
/>
</Frame>
<Frame label="Save" width={frameWidth}>
<Save
activeTag="sm_project_marketing"
onAdvance={saveStub.onAdvance}
onError={saveStub.onError}
prefill="The Q3 launch will target fintech enterprise buyers."
writableTags={mockWritableTags}
/>
</Frame>
<Frame label="Upload" width={frameWidth}>
<Upload
activeTag="sm_project_marketing"
onAdvance={uploadStub.onAdvance}
onError={uploadStub.onError}
writableTags={mockWritableTags}
/>
</Frame>
<Frame label="Confirmation" width={frameWidth}>
<Confirmation containerTag="sm_project_marketing" />
</Frame>
<Frame label="Success (save)" width={frameWidth}>
<Success
containerTag="sm_project_marketing"
id="mem_8fa92c"
kind="save"
/>
</Frame>
<Frame label="Success (upload)" width={frameWidth}>
<Success
containerTag="sm_project_marketing"
fileName="q3-brief.pdf"
id="doc_4b71a0"
kind="upload"
/>
</Frame>
<Frame label="Error" width={frameWidth}>
<ErrorView message="No write access to container tag 'sm_project_eng_rfcs'." />
</Frame>
<Frame label="Loading" width={frameWidth}>
<Loading />
</Frame>
<Frame label="Graph (@supermemory/memory-graph)" width={frameWidth}>
<Graph
documents={mockDocuments}
totalCount={mockDocuments.length}
/>
</Frame>
</div>
{(saveStub.event || uploadStub.event || pickerStub.event) && (
<Card>
<Stack gap="xs">
<span className="text-(length:--text-xs) uppercase tracking-wide text-text-muted">
Last event
</span>
{[pickerStub.event, saveStub.event, uploadStub.event]
.filter(Boolean)
.map((e) => (
<span
className="text-(length:--text-sm) font-mono text-text-secondary"
key={e}
>
{e}
</span>
))}
</Stack>
</Card>
)}
</Section>
</div>
</div>
)
}

View file

@ -0,0 +1,15 @@
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import { Studio } from "./Studio"
import "../design/globals.css"
// Standalone component gallery. No MCP host — pure visual review of every
// primitive and view with mock data. Run with `bun run studio`.
document.documentElement.setAttribute("data-theme", "light")
const root = createRoot(document.getElementById("studio") as HTMLElement)
root.render(
<StrictMode>
<Studio />
</StrictMode>,
)

View file

@ -0,0 +1,120 @@
import type {
ContainerTag,
ContainerTagAccess,
DocumentWithMemories,
} from "../../shared/types"
// Mock data for the Studio gallery. Mirrors the shapes the server returns
// so views render exactly as they would in Claude Desktop.
export const mockContainerTags: ContainerTag[] = [
{
id: "ct_1",
name: "Marketing",
containerTag: "sm_project_marketing",
createdAt: "2026-01-02T10:00:00Z",
updatedAt: "2026-05-30T14:20:00Z",
isExperimental: false,
emoji: "📣",
isNova: true,
documentCount: 42,
memoryCount: 318,
lastActivityAt: "2026-05-30T14:20:00Z",
},
{
id: "ct_2",
name: "Engineering RFCs",
containerTag: "sm_project_eng_rfcs",
createdAt: "2026-01-04T10:00:00Z",
updatedAt: "2026-05-29T09:00:00Z",
isExperimental: false,
emoji: "🛠️",
isNova: true,
documentCount: 17,
memoryCount: 96,
lastActivityAt: "2026-05-29T09:00:00Z",
},
{
id: "ct_3",
name: "Design System",
containerTag: "sm_project_design",
createdAt: "2026-02-01T10:00:00Z",
updatedAt: "2026-05-12T16:40:00Z",
isExperimental: false,
isNova: false,
documentCount: 8,
memoryCount: 1,
lastActivityAt: "2026-05-12T16:40:00Z",
},
{
id: "ct_4",
name: "Onboarding",
containerTag: "sm_project_onboarding",
createdAt: "2026-03-01T10:00:00Z",
updatedAt: "2026-03-01T10:00:00Z",
isExperimental: true,
emoji: "🚀",
isNova: false,
documentCount: 0,
memoryCount: 0,
lastActivityAt: null,
},
]
export const mockAssignedTags: ContainerTagAccess[] = [
{ containerTag: "sm_project_marketing", permission: "write" },
{ containerTag: "sm_project_eng_rfcs", permission: "read" },
{ containerTag: "sm_project_design", permission: "write" },
]
export const mockWritableTags: string[] = [
"sm_project_marketing",
"sm_project_design",
]
export const mockDocuments: DocumentWithMemories[] = [
{
id: "doc_1",
title: "Q3 Launch Brief",
summary: "Positioning and channels for the Q3 launch.",
type: "text",
createdAt: "2026-05-01T10:00:00Z",
updatedAt: "2026-05-01T10:00:00Z",
memoryEntries: [
{
id: "mem_1",
memory: "Q3 launch targets enterprise buyers in fintech.",
spaceId: "ct_1",
isLatest: true,
createdAt: "2026-05-01T10:00:00Z",
updatedAt: "2026-05-01T10:00:00Z",
},
{
id: "mem_2",
memory: "Primary channel is partner co-marketing webinars.",
spaceId: "ct_1",
isLatest: true,
createdAt: "2026-05-01T10:05:00Z",
updatedAt: "2026-05-01T10:05:00Z",
},
],
},
{
id: "doc_2",
title: "Brand Voice Guide",
summary: "Tone, vocabulary, and do/don't list.",
type: "text",
createdAt: "2026-04-12T10:00:00Z",
updatedAt: "2026-04-12T10:00:00Z",
memoryEntries: [
{
id: "mem_3",
memory: "Brand voice is confident, plain, never hypey.",
spaceId: "ct_1",
isLatest: true,
createdAt: "2026-04-12T10:00:00Z",
updatedAt: "2026-04-12T10:00:00Z",
},
],
},
]

View file

@ -0,0 +1,28 @@
import { Badge, Stack } from "../design/ui"
import { CheckCircle } from "../lib/icons"
interface Props {
containerTag: string
}
export function Confirmation({ containerTag }: Props) {
return (
<Stack
align="center"
className="px-(--page-header-px) py-(--space-10) text-center"
gap="md"
>
<CheckCircle className="size-12 text-success" />
<Stack align="center" gap="xs">
<div className="text-(length:--text-sm) font-medium text-text-primary">
Active workspace set
</div>
<Badge variant="accent">{containerTag}</Badge>
</Stack>
<p className="max-w-xs text-(length:--text-xs) text-text-muted">
All subsequent saves and recalls will use this workspace until you
change it.
</p>
</Stack>
)
}

View file

@ -0,0 +1,24 @@
import { Stack } from "../design/ui"
import { WarningCircle } from "../lib/icons"
interface Props {
message: string
}
export function ErrorView({ message }: Props) {
return (
<Stack
align="center"
className="px-(--page-header-px) py-(--space-10) text-center"
gap="md"
>
<WarningCircle className="size-12 text-error" />
<div className="text-(length:--text-sm) font-medium text-text-primary">
Something went wrong
</div>
<p className="max-w-sm text-(length:--text-xs) leading-relaxed text-text-muted">
{message}
</p>
</Stack>
)
}

View file

@ -0,0 +1,220 @@
import {
DEFAULT_COLORS,
type GraphApiDocument,
type GraphApiMemory,
type GraphThemeColors,
MemoryGraph,
type MemoryRelation,
} from "@supermemory/memory-graph"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import type {
DocumentMemoryEntry,
DocumentWithMemories,
} from "../../shared/types"
import { cn } from "../design/lib/cn"
import { useApp } from "../hooks/useApp"
import { useHostContext } from "../hooks/useHostContext"
import { useLog } from "../hooks/useLog"
import { ArrowsIn, ArrowsOut } from "../lib/icons"
// GraphThemeColors key → the --graph-* CSS variable it resolves from. Same
// mapping as the package's internal useGraphTheme, but we drive it ourselves
// so we can re-read at a reliable time (see useGraphColors).
const GRAPH_VARS: Record<keyof GraphThemeColors, string> = {
bg: "--graph-bg",
docFill: "--graph-doc-fill",
docStroke: "--graph-doc-stroke",
docInnerFill: "--graph-doc-inner",
memFill: "--graph-mem-fill",
memFillHover: "--graph-mem-fill-hover",
memStrokeDefault: "--graph-mem-stroke",
accent: "--graph-accent",
textPrimary: "--graph-text-primary",
textSecondary: "--graph-text-secondary",
textMuted: "--graph-text-muted",
edgeDerives: "--graph-edge-derives",
edgeUpdates: "--graph-edge-updates",
edgeExtends: "--graph-edge-extends",
memBorderForgotten: "--graph-mem-border-forgotten",
memBorderExpiring: "--graph-mem-border-expiring",
memBorderRecent: "--graph-mem-border-recent",
glowColor: "--graph-glow",
iconColor: "--graph-icon",
popoverBg: "--graph-popover-bg",
popoverBorder: "--graph-popover-border",
popoverTextPrimary: "--graph-popover-text-primary",
popoverTextSecondary: "--graph-popover-text-secondary",
popoverTextMuted: "--graph-popover-text-muted",
controlBg: "--graph-control-bg",
controlBorder: "--graph-control-border",
}
function readGraphColors(): GraphThemeColors {
const s = getComputedStyle(document.documentElement)
const out = {} as GraphThemeColors
for (const key of Object.keys(GRAPH_VARS) as (keyof GraphThemeColors)[]) {
out[key] = s.getPropertyValue(GRAPH_VARS[key]).trim() || DEFAULT_COLORS[key]
}
return out
}
// Read the graph palette from CSS, and re-read after a theme switch. We can't
// rely on the package's own useGraphTheme: it watches the `class` attribute,
// but the MCP host themes via `data-theme`, applied in a passive effect that
// runs AFTER children render. requestAnimationFrame fires after that effect,
// so by then `data-theme` (hence the --graph-* values) is current.
function useGraphColors(theme: string): GraphThemeColors {
const [colors, setColors] = useState<GraphThemeColors>(readGraphColors)
useEffect(() => {
// `theme` change is the trigger; re-read after the host applies
// data-theme (rAF fires after that passive effect).
if (!theme) return
const id = requestAnimationFrame(() => setColors(readGraphColors()))
return () => cancelAnimationFrame(id)
}, [theme])
return colors
}
interface Props {
documents: DocumentWithMemories[]
totalCount: number
containerTag?: string
}
type DisplayMode = "inline" | "fullscreen" | "pip"
// Map the widget's API shape (DocumentWithMemories) into the package's
// GraphApiDocument shape. Mirrors console-v2's use-graph-api transform so the
// graph renders identically to the console.
function toGraphMemory(mem: DocumentMemoryEntry): GraphApiMemory {
return {
id: mem.id,
memory: mem.memory ?? "",
isStatic: mem.isStatic ?? false,
spaceId: mem.spaceId ?? "",
isLatest: mem.isLatest ?? true,
isForgotten: mem.isForgotten ?? false,
forgetAfter: mem.forgetAfter ?? null,
forgetReason: mem.forgetReason ?? null,
version: mem.version ?? 1,
parentMemoryId: mem.parentMemoryId ?? null,
rootMemoryId: mem.rootMemoryId ?? null,
createdAt: mem.createdAt,
updatedAt: mem.updatedAt,
relation: null,
updatesMemoryId: null,
nextVersionId: null,
memoryRelations:
(mem.memoryRelations as Record<string, MemoryRelation> | undefined) ??
null,
spaceContainerTag: null,
}
}
function toGraphDocument(doc: DocumentWithMemories): GraphApiDocument {
return {
id: doc.id,
title: doc.title,
summary: doc.summary ?? null,
documentType: doc.type,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
memories: doc.memoryEntries.map(toGraphMemory),
}
}
export function Graph({ documents, totalCount }: Props) {
const { requestDisplayMode } = useApp()
const ctx = useHostContext()
const log = useLog()
const graphDocuments = useMemo(
() => documents.map(toGraphDocument),
[documents],
)
// Track display mode locally. The host doesn't reliably echo displayMode
// back via context, so an optimistic local toggle is the source of truth;
// we only adopt the host's value when it actually CHANGES (host-initiated
// exit, e.g. ESC at the host level). Without this the button is one-way.
const ctxMode = ctx?.displayMode as DisplayMode | undefined
const [mode, setMode] = useState<DisplayMode>(ctxMode ?? "inline")
const prevCtxMode = useRef(ctxMode)
useEffect(() => {
if (ctxMode !== prevCtxMode.current) {
prevCtxMode.current = ctxMode
if (ctxMode) setMode(ctxMode)
}
}, [ctxMode])
// Host theme (light/dark). Drives the graph palette reactively so a
// mid-session theme switch re-themes the canvas.
const theme = (ctx?.theme as string | undefined) ?? "light"
const colors = useGraphColors(theme)
const fullscreenSupported = useMemo(() => {
const modes = (
ctx as { availableDisplayModes?: string[] } | null | undefined
)?.availableDisplayModes
return Array.isArray(modes) ? modes.includes("fullscreen") : true
}, [ctx])
const toggleFullscreen = useCallback(async () => {
const next = mode === "fullscreen" ? "inline" : "fullscreen"
log("info", `[graph] fullscreen toggle: ${mode}${next}`)
setMode(next) // optimistic — button + container update immediately
try {
const result = await requestDisplayMode(next)
log("info", `[graph] requestDisplayMode result: ${result?.mode ?? "?"}`)
} catch (err) {
log("error", `[graph] requestDisplayMode failed: ${err}`)
}
}, [mode, requestDisplayMode, log])
// ESC exits fullscreen — matches Excalidraw and host-page UX.
useEffect(() => {
if (mode !== "fullscreen") return
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault()
void toggleFullscreen()
}
}
document.addEventListener("keydown", handler)
return () => document.removeEventListener("keydown", handler)
}, [mode, toggleFullscreen])
return (
<div className="relative">
<div className={cn("graph-view", mode === "fullscreen" && "fullscreen")}>
{/* No remount on toggle. The CSS just resizes the container; the
package's ResizeObserver re-lays-out and its node cache keeps
positions stable, so expand/minimize is instant with no reload.
Theme is fed reactively via the colors prop. */}
<MemoryGraph
colors={colors}
documents={graphDocuments}
totalCount={totalCount}
variant="console"
/>
</div>
{fullscreenSupported ? (
<button
aria-label={
mode === "fullscreen" ? "Exit fullscreen" : "Enter fullscreen"
}
className="absolute top-4 right-4 z-40 inline-flex items-center justify-center w-9 h-9 rounded-md bg-bg-elevated/80 backdrop-blur border border-border text-text-secondary hover:text-text-primary hover:bg-bg-muted transition-colors"
onClick={toggleFullscreen}
title={mode === "fullscreen" ? "Exit fullscreen" : "Fullscreen"}
type="button"
>
{mode === "fullscreen" ? (
<ArrowsIn className="size-4" />
) : (
<ArrowsOut className="size-4" />
)}
</button>
) : null}
</div>
)
}

View file

@ -0,0 +1,7 @@
export function Loading() {
return (
<div className="flex items-center justify-center py-(--space-12)">
<div className="spinner" />
</div>
)
}

View file

@ -0,0 +1,118 @@
import { useMemo, useState } from "react"
import type {
ContainerTag,
ContainerTagAccess,
ViewMessage,
} from "../../shared/types"
import { WorkspaceCard } from "../components/WorkspaceCard"
import { Input, PageHeader } from "../design/ui"
import { useApp } from "../hooks/useApp"
import { useLog } from "../hooks/useLog"
import { Search } from "../lib/icons"
interface Props {
containerTags: ContainerTag[]
activeTag?: string | null
assignedTags?: ContainerTagAccess[] | null
onAdvance: (msg: ViewMessage) => void
onError: (message: string) => void
}
// Show the search box once the list is long enough to need it.
const SEARCH_THRESHOLD = 8
export function Picker({
containerTags,
activeTag,
assignedTags,
onAdvance,
onError,
}: Props) {
const { callTool } = useApp()
const log = useLog()
const [pending, setPending] = useState<string | null>(null)
const [query, setQuery] = useState("")
const filtered = useMemo(() => {
if (!query.trim()) return containerTags
const q = query.trim().toLowerCase()
return containerTags.filter(
(t) =>
t.name.toLowerCase().includes(q) ||
t.containerTag.toLowerCase().includes(q),
)
}, [containerTags, query])
const handleSelect = async (containerTag: string) => {
log("info", `[picker] select: ${containerTag}`)
setPending(containerTag)
const result = await callTool<ViewMessage>("set-active-tag", {
containerTag,
})
setPending(null)
if (!result.ok || !result.data) {
log("error", `[picker] set-active-tag failed: ${result.error}`)
onError(result.error ?? "Failed to set active workspace")
return
}
onAdvance(result.data)
}
const count = containerTags.length
const description =
count === 0
? "No workspaces available."
: `${count} workspace${count === 1 ? "" : "s"} available — select one to set your active context.`
return (
<div className="flex flex-col">
<PageHeader description={description} title="Select Workspace" />
<div className="flex flex-col gap-(--space-4) px-(--page-header-px) pb-(--space-6)">
{count >= SEARCH_THRESHOLD ? (
<div className="relative max-w-sm">
<Search className="pointer-events-none absolute left-(--space-3) top-1/2 size-4 -translate-y-1/2 text-text-muted" />
<Input
className="pl-(--space-8)"
onChange={(e) => setQuery(e.target.value)}
placeholder="Search workspaces…"
value={query}
/>
</div>
) : null}
{/* Bounded, scrollable list keeps a stable height so filtering or a
long workspace list doesn't resize (jump) the whole widget. */}
<div className="min-h-[220px] max-h-[60vh] overflow-y-auto pr-1">
{filtered.length === 0 ? (
<p className="py-(--space-6) text-center text-(length:--text-sm) text-text-muted">
No workspaces match {query}.
</p>
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-(--space-3)">
{filtered.map((tag) => {
const access = assignedTags?.find(
(t) => t.containerTag === tag.containerTag,
)
return (
<WorkspaceCard
access={access}
active={activeTag === tag.containerTag}
containerTag={tag}
key={tag.id || tag.containerTag}
onClick={handleSelect}
/>
)
})}
</div>
)}
</div>
{pending ? (
<p className="text-(length:--text-xs) text-text-muted">
Setting workspace to {pending}
</p>
) : null}
</div>
</div>
)
}

View file

@ -0,0 +1,112 @@
import { useEffect, useMemo, useState } from "react"
import type { ViewMessage } from "../../shared/types"
import {
ActionGroup,
Button,
Field,
PageHeader,
Stack,
TextArea,
WorkspaceSelect,
} from "../design/ui"
import { useApp } from "../hooks/useApp"
import { useLog } from "../hooks/useLog"
interface Props {
activeTag?: string | null
writableTags: string[]
prefill?: string
onAdvance: (msg: ViewMessage) => void
onError: (message: string) => void
}
export function Save({
activeTag,
writableTags,
prefill,
onAdvance,
onError,
}: Props) {
const { callTool } = useApp()
const log = useLog()
const [content, setContent] = useState(prefill ?? "")
const [selectedTag, setSelectedTag] = useState<string | null>(
activeTag ?? writableTags[0] ?? null,
)
const [saving, setSaving] = useState(false)
useEffect(() => {
if (!selectedTag && writableTags.length > 0) {
setSelectedTag(writableTags[0])
}
}, [selectedTag, writableTags])
const options = useMemo(
() => writableTags.map((tag) => ({ value: tag, label: tag })),
[writableTags],
)
const trimmed = content.trim()
const canSave = trimmed.length > 0 && !!selectedTag && !saving
const handleSave = async () => {
if (!canSave || !selectedTag) return
log("info", `[save] submit (${trimmed.length} chars → ${selectedTag})`)
setSaving(true)
const result = await callTool<ViewMessage>("save-memory", {
content: trimmed,
containerTag: selectedTag,
})
setSaving(false)
if (!result.ok || !result.data) {
log("error", `[save] failed: ${result.error}`)
onError(result.error ?? "Failed to save memory")
return
}
onAdvance(result.data)
}
return (
<div className="flex flex-col">
<PageHeader
description="Capture a thought to a workspace your team can search later."
title="Add Memory"
/>
<div className="px-(--page-header-px) pb-(--space-6)">
<Stack gap="lg">
<Field label="Memory">
<TextArea
className="min-h-40"
onChange={(e) => setContent(e.target.value)}
placeholder="Enter content to save as a memory…"
value={content}
/>
</Field>
{writableTags.length > 0 ? (
<Field label="Workspace">
<WorkspaceSelect
onValueChange={setSelectedTag}
options={options}
value={selectedTag}
/>
</Field>
) : null}
<div className="flex justify-end border-t border-border-muted pt-(--space-4)">
<ActionGroup>
<Button
disabled={!canSave}
loading={saving}
onClick={handleSave}
variant="primary"
>
{saving ? "Saving" : "Save memory"}
</Button>
</ActionGroup>
</div>
</Stack>
</div>
</div>
)
}

View file

@ -0,0 +1,39 @@
import { Badge, Stack } from "../design/ui"
import { CheckCircle } from "../lib/icons"
interface SaveProps {
kind: "save"
id: string
containerTag: string
}
interface UploadProps {
kind: "upload"
id: string
fileName: string
containerTag: string
}
type Props = SaveProps | UploadProps
export function Success(props: Props) {
const isUpload = props.kind === "upload"
return (
<Stack
align="center"
className="px-(--page-header-px) py-(--space-10) text-center"
gap="md"
>
<CheckCircle className="size-12 text-success" />
<Stack align="center" gap="xs">
<div className="text-(length:--text-sm) font-medium text-text-primary">
{isUpload ? `Uploaded ${props.fileName}` : "Memory saved"}
</div>
<Badge variant="accent">{props.containerTag}</Badge>
</Stack>
<p className="break-all font-mono text-(length:--text-xs) text-text-muted">
{props.id}
</p>
</Stack>
)
}

View file

@ -0,0 +1,137 @@
import { useMemo, useState } from "react"
import type { ViewMessage } from "../../shared/types"
import {
ActionGroup,
Button,
Field,
FileUpload,
PageHeader,
Stack,
WorkspaceSelect,
} from "../design/ui"
import { useApp } from "../hooks/useApp"
import { useLog } from "../hooks/useLog"
import { FileText, X } from "../lib/icons"
import { readFileAsBase64 } from "../lib/readFileAsBase64"
interface Props {
activeTag?: string | null
writableTags: string[]
onAdvance: (msg: ViewMessage) => void
onError: (message: string) => void
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
const ACCEPT = ".txt,.pdf,.png,.jpg,.jpeg,.mp4"
export function Upload({ activeTag, writableTags, onAdvance, onError }: Props) {
const { callTool } = useApp()
const log = useLog()
const [file, setFile] = useState<File | null>(null)
const [selectedTag, setSelectedTag] = useState<string | null>(
activeTag ?? writableTags[0] ?? null,
)
const [uploading, setUploading] = useState(false)
const options = useMemo(
() => writableTags.map((tag) => ({ value: tag, label: tag })),
[writableTags],
)
const canUpload = !!file && !!selectedTag && !uploading
const handleUpload = async () => {
if (!file || !selectedTag) return
log("info", `[upload] submit ${file.name} (${file.size}B → ${selectedTag})`)
setUploading(true)
try {
const fileData = await readFileAsBase64(file)
const result = await callTool<ViewMessage>("upload-file-submit", {
fileData,
fileName: file.name,
mimeType: file.type,
containerTag: selectedTag,
})
if (!result.ok || !result.data) {
log("error", `[upload] failed: ${result.error}`)
onError(result.error ?? "Upload failed")
return
}
onAdvance(result.data)
} catch (err) {
log("error", `[upload] threw: ${err}`)
onError(String(err))
} finally {
setUploading(false)
}
}
return (
<div className="flex flex-col">
<PageHeader
description="Send a file (text, PDF, image, video) into a workspace."
title="Upload File"
/>
<div className="px-(--page-header-px) pb-(--space-6)">
<Stack gap="lg">
{file ? (
<div className="flex items-center justify-between gap-(--space-3) rounded-(--radius-lg) border border-border bg-bg-elevated p-(--space-3)">
<div className="flex min-w-0 items-center gap-(--space-3)">
<FileText className="size-5 shrink-0 text-text-secondary" />
<div className="flex min-w-0 flex-col">
<span className="truncate text-(length:--text-sm) font-medium text-text-primary">
{file.name}
</span>
<span className="text-(length:--text-xs) font-mono text-text-muted">
{formatFileSize(file.size)}
</span>
</div>
</div>
<Button
aria-label="Remove file"
iconLeft={<X className="size-4" />}
onClick={() => setFile(null)}
size="icon"
variant="ghost"
/>
</div>
) : (
<FileUpload
accept={ACCEPT}
description="Supports TXT, PDF, PNG, JPG, MP4"
onFile={setFile}
/>
)}
{writableTags.length > 0 ? (
<Field label="Workspace">
<WorkspaceSelect
onValueChange={setSelectedTag}
options={options}
value={selectedTag}
/>
</Field>
) : null}
<div className="flex justify-end border-t border-border-muted pt-(--space-4)">
<ActionGroup>
<Button
disabled={!canUpload}
loading={uploading}
onClick={handleUpload}
variant="primary"
>
{uploading ? "Uploading" : "Upload file"}
</Button>
</ActionGroup>
</div>
</Stack>
</div>
</div>
)
}

View file

@ -7,16 +7,13 @@
"skipLibCheck": true,
"lib": ["ESNext"],
"types": ["@cloudflare/workers-types"],
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx",
"esModuleInterop": true,
"resolveJsonModule": true,
"outDir": "dist",
"rootDir": "src",
"paths": {
"*": ["./*"]
}
"baseUrl": ".",
"noEmit": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
"include": ["src/server/**/*", "src/shared/**/*"],
"exclude": ["node_modules", "dist"]
}

View file

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"jsx": "react-jsx",
"esModuleInterop": true,
"resolveJsonModule": true,
"baseUrl": ".",
"noEmit": true
},
"include": ["src/widget/**/*", "src/shared/**/*"],
"exclude": ["node_modules", "dist"]
}

View file

@ -0,0 +1,26 @@
import { resolve } from "node:path"
import tailwindcss from "@tailwindcss/vite"
import react from "@vitejs/plugin-react"
import { defineConfig } from "vite"
// DEV config: full HMR loop, deps from node_modules. Use with `bun run dev:widget`
// to iterate on the widget without rebuilding the Worker. Run the Worker via
// `bun run dev` separately when testing the full server↔widget flow.
export default defineConfig({
plugins: [tailwindcss(), react()],
root: resolve(__dirname, "src/widget"),
// Force a single react/react-dom copy. @supermemory/memory-graph (and its
// dep optimization) can otherwise pull react-dom through a different
// resolution path than the app's react, tripping react-dom's
// "Incompatible React versions" identity check. Prod doesn't need this —
// it externalizes both to one esm.sh URL.
resolve: {
dedupe: ["react", "react-dom"],
},
optimizeDeps: {
include: ["react", "react-dom", "react-dom/client"],
},
server: {
port: 5173,
},
})

View file

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

View file

@ -2,9 +2,9 @@
"$schema": "node_modules/wrangler/config-schema.json",
"name": "supermemory-mcp",
"build": {
"command": "bun run build:ui"
"command": "bun run build:widget"
},
"main": "src/index.ts",
"main": "src/server/index.ts",
"compatibility_date": "2025-01-01",
"compatibility_flags": ["nodejs_compat"],
@ -13,6 +13,7 @@
"vars": {
"API_URL": "https://api.supermemory.ai"
},
"routes": [
{
"pattern": "mcp.supermemory.ai",
@ -20,11 +21,16 @@
"custom_domain": true
}
],
"kv_namespaces": [
{ "binding": "AUTH_CACHE", "id": "REPLACE_WITH_KV_NAMESPACE_ID" }
],
"durable_objects": {
"bindings": [
{
"name": "MCP_SERVER",
"class_name": "SupermemoryMCP"
"class_name": "EnterpriseMCP"
}
]
},
@ -32,7 +38,7 @@
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["SupermemoryMCP"]
"new_sqlite_classes": ["EnterpriseMCP"]
}
],

View file

@ -85,25 +85,36 @@
},
"apps/mcp": {
"name": "supermemory-mcp",
"version": "4.0.0",
"version": "1.0.0",
"dependencies": {
"@cloudflare/workers-oauth-provider": "^0.2.2",
"@modelcontextprotocol/ext-apps": "^1.0.0",
"@modelcontextprotocol/sdk": "^1.25.2",
"@phosphor-icons/react": "^2.1.10",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tooltip": "^1.2.7",
"@supermemory/memory-graph": "^0.2.0",
"agents": "^0.3.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"hono": "^4.11.1",
"posthog-node": "^5.18.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"supermemory": "^4.0.0",
"tailwind-merge": "^3.4.0",
"zod": "^3.25.76",
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250620.0",
"d3-force-3d": "^3.0.5",
"force-graph": "^1.49.0",
"@tailwindcss/vite": "^4.1.13",
"@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.1.13",
"typescript": "^5.8.3",
"vite": "^6.0.0",
"vite-plugin-singlefile": "^2.3.0",
"vitest": "^3.2.4",
"wrangler": "^4.4.0",
},
},
@ -1279,6 +1290,8 @@
"@peculiar/x509": ["@peculiar/x509@1.14.3", "", { "dependencies": { "@peculiar/asn1-cms": "^2.6.0", "@peculiar/asn1-csr": "^2.6.0", "@peculiar/asn1-ecc": "^2.6.0", "@peculiar/asn1-pkcs9": "^2.6.0", "@peculiar/asn1-rsa": "^2.6.0", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.0", "pvtsutils": "^1.3.6", "reflect-metadata": "^0.2.2", "tslib": "^2.8.1", "tsyringe": "^4.10.0" } }, "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA=="],
"@phosphor-icons/react": ["@phosphor-icons/react@2.1.10", "", { "peerDependencies": { "react": ">= 16.8", "react-dom": ">= 16.8" } }, "sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA=="],
"@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
"@pixi/colord": ["@pixi/colord@2.9.6", "", {}, "sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA=="],
@ -1963,8 +1976,6 @@
"@tsconfig/node18": ["@tsconfig/node18@1.0.3", "", {}, "sha512-RbwvSJQsuN9TB04AQbGULYfOGE/RnSFk/FLQ5b0NmDf5Kx2q/lABZbHQPKCO1vZ6Fiwkplu+yb9pGdLy1iGseQ=="],
"@tweenjs/tween.js": ["@tweenjs/tween.js@25.0.0", "", {}, "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@types/acorn": ["@types/acorn@4.0.6", "", { "dependencies": { "@types/estree": "*" } }, "sha512-veQTnWP+1D/xbxVrPC3zHnCZRjSrKfhbMUlEA43iMZLu7EsnTtkJklIuwrCPbOi8YkvDQAiW05VQQFvvz9oieQ=="],
@ -2255,8 +2266,6 @@
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
"accessor-fn": ["accessor-fn@1.5.3", "", {}, "sha512-rkAofCwe/FvYFUlMB0v0gWmhqtfAtV1IUkdPbfhTUyYniu5LrC0A0UJkTH0Jv3S8SvwkmfuAlY+mQIJATdocMA=="],
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"acorn-import-attributes": ["acorn-import-attributes@1.9.5", "", { "peerDependencies": { "acorn": "^8" } }, "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ=="],
@ -2419,8 +2428,6 @@
"better-opn": ["better-opn@3.0.2", "", { "dependencies": { "open": "^8.0.4" } }, "sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ=="],
"bezier-js": ["bezier-js@6.1.4", "", {}, "sha512-PA0FW9ZpcHbojUCMu28z9Vg/fNkwTj5YhusSAjHHDfHDGLxJ6YUKrAN2vk1fP2MMOxVw4Oko16FMlRGVBGqLKg=="],
"bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="],
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
@ -2481,8 +2488,6 @@
"caniuse-lite": ["caniuse-lite@1.0.30001776", "", {}, "sha512-sg01JDPzZ9jGshqKSckOQthXnYwOEP50jeVFhaSFbZcOy05TiuuaffDOfcwtCisJ9kNQuLBFibYywv2Bgm9osw=="],
"canvas-color-tracker": ["canvas-color-tracker@1.3.2", "", { "dependencies": { "tinycolor2": "^1.6.0" } }, "sha512-ryQkDX26yJ3CXzb3hxUVNlg1NKE4REc5crLBq661Nxzr8TNd236SaEf2ffYLXyI5tSABSeguHLqcVq4vf9L3Zg=="],
"canvas-confetti": ["canvas-confetti@1.9.4", "", {}, "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw=="],
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
@ -2651,8 +2656,6 @@
"d3-axis": ["d3-axis@3.0.0", "", {}, "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw=="],
"d3-binarytree": ["d3-binarytree@1.0.2", "", {}, "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw=="],
"d3-brush": ["d3-brush@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "3", "d3-transition": "3" } }, "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ=="],
"d3-chord": ["d3-chord@3.0.1", "", { "dependencies": { "d3-path": "1 - 3" } }, "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g=="],
@ -2675,8 +2678,6 @@
"d3-force": ["d3-force@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg=="],
"d3-force-3d": ["d3-force-3d@3.0.6", "", { "dependencies": { "d3-binarytree": "1", "d3-dispatch": "1 - 3", "d3-octree": "1", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-4tsKHUPLOVkyfEffZo1v6sFHvGFwAIIjt/W8IThbp08DYAsXZck+2pSHEG5W1+gQgEvFLdZkYvmJAbRM2EzMnA=="],
"d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="],
"d3-geo": ["d3-geo@3.1.1", "", { "dependencies": { "d3-array": "2.5.0 - 3" } }, "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q=="],
@ -2685,8 +2686,6 @@
"d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
"d3-octree": ["d3-octree@1.1.0", "", {}, "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A=="],
"d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
"d3-polygon": ["d3-polygon@1.0.6", "", {}, "sha512-k+RF7WvI08PC8reEoXa/w2nSg5AUMTi+peBD9cmFc+0ixHfbs4QmxxkarVal1IkVkgxVuk9JSHhJURHiyHKAuQ=="],
@ -3071,8 +3070,6 @@
"flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="],
"float-tooltip": ["float-tooltip@1.7.5", "", { "dependencies": { "d3-selection": "2 - 3", "kapsule": "^1.16", "preact": "10" } }, "sha512-/kXzuDnnBqyyWyhDMH7+PfP8J/oXiAavGzcRxASOMRHFuReDtofizLLJsf7nnDLAfEaMW4pVWaXrAjtnglpEkg=="],
"flubber": ["flubber@0.4.2", "", { "dependencies": { "d3-array": "^1.2.0", "d3-polygon": "^1.0.3", "earcut": "^2.1.1", "svg-path-properties": "^0.2.1", "svgpath": "^2.2.1", "topojson-client": "^3.0.0" } }, "sha512-79RkJe3rA4nvRCVc2uXjj7U/BAUq84TS3KHn6c0Hr9K64vhj83ZNLUziNx4pJoBumSPhOl5VjH+Z0uhi+eE8Uw=="],
"fn.name": ["fn.name@1.1.0", "", {}, "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw=="],
@ -3081,8 +3078,6 @@
"for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="],
"force-graph": ["force-graph@1.51.2", "", { "dependencies": { "@tweenjs/tween.js": "18 - 25", "accessor-fn": "1", "bezier-js": "3 - 6", "canvas-color-tracker": "^1.3", "d3-array": "1 - 3", "d3-drag": "2 - 3", "d3-force-3d": "2 - 3", "d3-scale": "1 - 4", "d3-scale-chromatic": "1 - 3", "d3-selection": "2 - 3", "d3-zoom": "2 - 3", "float-tooltip": "^1.7", "index-array-by": "1", "kapsule": "^1.16", "lodash-es": "4" } }, "sha512-zZNdMqx8qIQGurgnbgYIUsdXxSfvhfRSIdncsKGv/twUOZpwCsk9hPHmdjdcme1+epATgb41G0rkIGHJ0Wydng=="],
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
@ -3315,8 +3310,6 @@
"indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="],
"index-array-by": ["index-array-by@1.4.2", "", {}, "sha512-SP23P27OUKzXWEC/TOyWlwLviofQkCSCKONnc62eItjp69yCZZPqDQtr3Pw5gJDnPeUMqExmKydNZaJO0FU9pw=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"ini": ["ini@4.1.3", "", {}, "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg=="],
@ -3533,8 +3526,6 @@
"jwt-decode": ["jwt-decode@4.0.0", "", {}, "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA=="],
"kapsule": ["kapsule@1.16.3", "", { "dependencies": { "lodash-es": "4" } }, "sha512-4+5mNNf4vZDSwPhKprKwz3330iisPrb08JyMgbsdFrimBCKNHecua/WBwvVg3n7vwx0C1ARjfhwIpbrbd9n5wg=="],
"katex": ["katex@0.16.35", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-S0+riEvy1CK4VKse1ivMff8gmabe/prY7sKB3njjhyoLLsNFDQYtKNgXrbWUggGDCJBz7Fctl5i8fLCESHXzSg=="],
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
@ -4145,8 +4136,6 @@
"posthog-js": ["posthog-js@1.359.0", "", { "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/api-logs": "^0.208.0", "@opentelemetry/exporter-logs-otlp-http": "^0.208.0", "@opentelemetry/resources": "^2.2.0", "@opentelemetry/sdk-logs": "^0.208.0", "@posthog/core": "1.23.2", "@posthog/types": "1.359.0", "core-js": "^3.38.1", "dompurify": "^3.3.1", "fflate": "^0.4.8", "preact": "^10.28.2", "query-selector-shadow-dom": "^1.0.1", "web-vitals": "^5.1.0" } }, "sha512-W3yrLKgJfc9qm7Z9e7LblPdCuSJsfB8xzg36GSklyFETqb5b8KtmF2cx/tiXMGEDNmKPPJ+oex/N9Q+vdTjPug=="],
"posthog-node": ["posthog-node@5.28.0", "", { "dependencies": { "@posthog/core": "1.23.2" }, "peerDependencies": { "rxjs": "^7.0.0" }, "optionalPeers": ["rxjs"] }, "sha512-EETYV0zA+7BLQmXzY+vGyDMoQK8uHf8f/1utbRjKncI41gPkw+4piGP7l4UT5Luld+4vQpJPOR1q1YrbXm7XjQ=="],
"powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="],
"preact": ["preact@10.28.4", "", {}, "sha512-uKFfOHWuSNpRFVTnljsCluEFq57OKT+0QdOiQo8XWnQ/pSvg7OpX5eNOejELXJMWy+BwM2nobz0FkvzmnpCNsQ=="],
@ -4701,8 +4690,6 @@
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
"tinycolor2": ["tinycolor2@1.6.0", "", {}, "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="],
"tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
@ -5731,8 +5718,6 @@
"flubber/earcut": ["earcut@2.2.4", "", {}, "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ=="],
"force-graph/d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
"front-matter/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="],
"fs-minipass/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
@ -5979,6 +5964,8 @@
"supermemory-browser-extension/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"supermemory-mcp/@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
"supermemory-mcp/agents": ["agents@0.3.10", "", { "dependencies": { "@cfworker/json-schema": "^4.1.1", "@modelcontextprotocol/sdk": "1.25.2", "cron-schedule": "^6.0.0", "escape-html": "^1.0.3", "json-schema": "^0.4.0", "json-schema-to-typescript": "^15.0.4", "mimetext": "^3.0.28", "nanoid": "^5.1.6", "partyserver": "^0.1.2", "partysocket": "1.1.11", "yargs": "^18.0.0" }, "peerDependencies": { "@ai-sdk/openai": "^3.0.0", "@ai-sdk/react": "^3.0.0", "@cloudflare/ai-chat": "^0.0.6", "@cloudflare/codemode": "^0.0.6", "ai": "^6.0.0", "react": "^19.0.0", "viem": ">=2.0.0", "x402": "^0.7.1", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@ai-sdk/openai", "@ai-sdk/react", "viem", "x402"], "bin": { "agents": "dist/cli/index.js" } }, "sha512-hKj3nbej14GA2SgE6/stQheJF35LCS7DxMuHG0QDl1/npnnECYyG0Yf5DXl6AvJAZOFNDwT3iEzKi8yLZngPDA=="],
"supermemory-mcp/supermemory": ["supermemory@4.21.1", "", { "bin": { "supermemory": "bin/cli" } }, "sha512-KayOHtD94g7O+yN2qxaHEO5UIXtDl+duaKuhW7gvaraVtP1RHxFn80Pb5s5rKmqIvC+ruaARRlgMw7s/y+6LGQ=="],
@ -6735,6 +6722,10 @@
"string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"supermemory-mcp/@vitejs/plugin-react/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
"supermemory-mcp/@vitejs/plugin-react/react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
"supermemory-mcp/agents/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.2", "", { "dependencies": { "@hono/node-server": "^1.19.7", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww=="],
"supermemory-mcp/agents/partysocket": ["partysocket@1.1.11", "", { "dependencies": { "event-target-polyfill": "^0.0.4" } }, "sha512-P0EtOQiAwvLriqLgdThcSaREfz3bP77LkLSdmXq680BosPKvGSoGTh/d0g3S+UNmaqcw89Ad7JXHHKyRx3xU9Q=="],