From a95b9e9052211a7e23ebdf30dae28e65c1036867 Mon Sep 17 00:00:00 2001 From: Prasanna A P <106952318+Prasanna721@users.noreply.github.com> Date: Mon, 15 Jun 2026 15:43:22 -0700 Subject: [PATCH 1/7] 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) --- apps/mcp/mcp-app.html | 95 -- apps/mcp/package.json | 77 +- apps/mcp/src/auth.ts | 161 --- apps/mcp/src/format.ts | 157 --- apps/mcp/src/posthog.ts | 135 --- apps/mcp/src/server.ts | 778 --------------- apps/mcp/src/server/agent.ts | 115 +++ apps/mcp/src/server/auth/cache.ts | 111 +++ apps/mcp/src/server/auth/index.ts | 158 +++ apps/mcp/src/server/auth/rbac.ts | 42 + .../src/{client.ts => server/client/index.ts} | 248 ++--- apps/mcp/src/{ => server}/html.d.ts | 0 apps/mcp/src/{ => server}/index.ts | 155 +-- apps/mcp/src/server/prompts/context.ts | 108 ++ .../src/server/resources/container-tags.ts | 26 + apps/mcp/src/server/resources/profile.ts | 45 + apps/mcp/src/server/resources/widget.ts | 49 + apps/mcp/src/server/tools/add-memory.ts | 69 ++ apps/mcp/src/server/tools/fetch-graph-data.ts | 56 ++ apps/mcp/src/server/tools/guided-save.ts | 45 + apps/mcp/src/server/tools/index.ts | 33 + .../src/server/tools/list-container-tags.ts | 46 + apps/mcp/src/server/tools/memory-graph.ts | 67 ++ apps/mcp/src/server/tools/save-memory.ts | 51 + apps/mcp/src/server/tools/search-memory.ts | 90 ++ apps/mcp/src/server/tools/select-workspace.ts | 44 + apps/mcp/src/server/tools/set-active-tag.ts | 45 + apps/mcp/src/server/tools/types.ts | 35 + .../src/server/tools/upload-file-submit.ts | 76 ++ apps/mcp/src/server/tools/upload-file.ts | 40 + apps/mcp/src/server/tools/who-am-i.ts | 34 + apps/mcp/src/shared/types.ts | 111 +++ apps/mcp/src/ui/global.css | 46 - apps/mcp/src/ui/mcp-app.css | 303 ------ apps/mcp/src/ui/mcp-app.ts | 934 ------------------ apps/mcp/src/widget/App.tsx | 102 ++ apps/mcp/src/widget/ErrorBoundary.tsx | 58 ++ .../src/widget/components/PermissionBadge.tsx | 17 + apps/mcp/src/widget/components/Spinner.tsx | 3 + .../src/widget/components/WorkspaceCard.tsx | 66 ++ apps/mcp/src/widget/design/globals.css | 143 +++ apps/mcp/src/widget/design/lib/cn.ts | 6 + apps/mcp/src/widget/design/tokens.css | 222 +++++ apps/mcp/src/widget/design/ui/ActionGroup.tsx | 19 + apps/mcp/src/widget/design/ui/Badge.tsx | 43 + apps/mcp/src/widget/design/ui/Button.tsx | 114 +++ apps/mcp/src/widget/design/ui/Card.tsx | 56 ++ apps/mcp/src/widget/design/ui/Chip.tsx | 35 + apps/mcp/src/widget/design/ui/Field.tsx | 29 + apps/mcp/src/widget/design/ui/FileUpload.tsx | 99 ++ apps/mcp/src/widget/design/ui/Input.tsx | 57 ++ apps/mcp/src/widget/design/ui/PageHeader.tsx | 53 + apps/mcp/src/widget/design/ui/Popover.tsx | 35 + apps/mcp/src/widget/design/ui/Stack.tsx | 50 + apps/mcp/src/widget/design/ui/TextArea.tsx | 35 + apps/mcp/src/widget/design/ui/Tooltip.tsx | 46 + .../src/widget/design/ui/WorkspaceSelect.tsx | 158 +++ apps/mcp/src/widget/design/ui/index.ts | 24 + apps/mcp/src/widget/hooks/useApp.ts | 82 ++ .../mcp/src/widget/hooks/useApplyHostTheme.ts | 62 ++ apps/mcp/src/widget/hooks/useHostContext.ts | 25 + apps/mcp/src/widget/hooks/useLog.ts | 18 + apps/mcp/src/widget/hooks/useViewState.ts | 91 ++ apps/mcp/src/widget/index.html | 15 + apps/mcp/src/widget/lib/app.ts | 7 + apps/mcp/src/widget/lib/icons.ts | 48 + apps/mcp/src/widget/lib/readFileAsBase64.ts | 15 + apps/mcp/src/widget/main.tsx | 18 + apps/mcp/src/widget/studio.html | 15 + apps/mcp/src/widget/studio/Studio.tsx | 430 ++++++++ apps/mcp/src/widget/studio/main.tsx | 15 + apps/mcp/src/widget/studio/mocks.ts | 120 +++ apps/mcp/src/widget/views/Confirmation.tsx | 28 + apps/mcp/src/widget/views/Error.tsx | 24 + apps/mcp/src/widget/views/Graph.tsx | 220 +++++ apps/mcp/src/widget/views/Loading.tsx | 7 + apps/mcp/src/widget/views/Picker.tsx | 118 +++ apps/mcp/src/widget/views/Save.tsx | 112 +++ apps/mcp/src/widget/views/Success.tsx | 39 + apps/mcp/src/widget/views/Upload.tsx | 137 +++ apps/mcp/tsconfig.json | 11 +- apps/mcp/tsconfig.widget.json | 18 + apps/mcp/vite.config.dev.ts | 26 + apps/mcp/vite.config.ts | 20 +- apps/mcp/wrangler.jsonc | 14 +- bun.lock | 57 +- 86 files changed, 4930 insertions(+), 2917 deletions(-) delete mode 100644 apps/mcp/mcp-app.html delete mode 100644 apps/mcp/src/auth.ts delete mode 100644 apps/mcp/src/format.ts delete mode 100644 apps/mcp/src/posthog.ts delete mode 100644 apps/mcp/src/server.ts create mode 100644 apps/mcp/src/server/agent.ts create mode 100644 apps/mcp/src/server/auth/cache.ts create mode 100644 apps/mcp/src/server/auth/index.ts create mode 100644 apps/mcp/src/server/auth/rbac.ts rename apps/mcp/src/{client.ts => server/client/index.ts} (57%) rename apps/mcp/src/{ => server}/html.d.ts (100%) rename apps/mcp/src/{ => server}/index.ts (52%) create mode 100644 apps/mcp/src/server/prompts/context.ts create mode 100644 apps/mcp/src/server/resources/container-tags.ts create mode 100644 apps/mcp/src/server/resources/profile.ts create mode 100644 apps/mcp/src/server/resources/widget.ts create mode 100644 apps/mcp/src/server/tools/add-memory.ts create mode 100644 apps/mcp/src/server/tools/fetch-graph-data.ts create mode 100644 apps/mcp/src/server/tools/guided-save.ts create mode 100644 apps/mcp/src/server/tools/index.ts create mode 100644 apps/mcp/src/server/tools/list-container-tags.ts create mode 100644 apps/mcp/src/server/tools/memory-graph.ts create mode 100644 apps/mcp/src/server/tools/save-memory.ts create mode 100644 apps/mcp/src/server/tools/search-memory.ts create mode 100644 apps/mcp/src/server/tools/select-workspace.ts create mode 100644 apps/mcp/src/server/tools/set-active-tag.ts create mode 100644 apps/mcp/src/server/tools/types.ts create mode 100644 apps/mcp/src/server/tools/upload-file-submit.ts create mode 100644 apps/mcp/src/server/tools/upload-file.ts create mode 100644 apps/mcp/src/server/tools/who-am-i.ts create mode 100644 apps/mcp/src/shared/types.ts delete mode 100644 apps/mcp/src/ui/global.css delete mode 100644 apps/mcp/src/ui/mcp-app.css delete mode 100644 apps/mcp/src/ui/mcp-app.ts create mode 100644 apps/mcp/src/widget/App.tsx create mode 100644 apps/mcp/src/widget/ErrorBoundary.tsx create mode 100644 apps/mcp/src/widget/components/PermissionBadge.tsx create mode 100644 apps/mcp/src/widget/components/Spinner.tsx create mode 100644 apps/mcp/src/widget/components/WorkspaceCard.tsx create mode 100644 apps/mcp/src/widget/design/globals.css create mode 100644 apps/mcp/src/widget/design/lib/cn.ts create mode 100644 apps/mcp/src/widget/design/tokens.css create mode 100644 apps/mcp/src/widget/design/ui/ActionGroup.tsx create mode 100644 apps/mcp/src/widget/design/ui/Badge.tsx create mode 100644 apps/mcp/src/widget/design/ui/Button.tsx create mode 100644 apps/mcp/src/widget/design/ui/Card.tsx create mode 100644 apps/mcp/src/widget/design/ui/Chip.tsx create mode 100644 apps/mcp/src/widget/design/ui/Field.tsx create mode 100644 apps/mcp/src/widget/design/ui/FileUpload.tsx create mode 100644 apps/mcp/src/widget/design/ui/Input.tsx create mode 100644 apps/mcp/src/widget/design/ui/PageHeader.tsx create mode 100644 apps/mcp/src/widget/design/ui/Popover.tsx create mode 100644 apps/mcp/src/widget/design/ui/Stack.tsx create mode 100644 apps/mcp/src/widget/design/ui/TextArea.tsx create mode 100644 apps/mcp/src/widget/design/ui/Tooltip.tsx create mode 100644 apps/mcp/src/widget/design/ui/WorkspaceSelect.tsx create mode 100644 apps/mcp/src/widget/design/ui/index.ts create mode 100644 apps/mcp/src/widget/hooks/useApp.ts create mode 100644 apps/mcp/src/widget/hooks/useApplyHostTheme.ts create mode 100644 apps/mcp/src/widget/hooks/useHostContext.ts create mode 100644 apps/mcp/src/widget/hooks/useLog.ts create mode 100644 apps/mcp/src/widget/hooks/useViewState.ts create mode 100644 apps/mcp/src/widget/index.html create mode 100644 apps/mcp/src/widget/lib/app.ts create mode 100644 apps/mcp/src/widget/lib/icons.ts create mode 100644 apps/mcp/src/widget/lib/readFileAsBase64.ts create mode 100644 apps/mcp/src/widget/main.tsx create mode 100644 apps/mcp/src/widget/studio.html create mode 100644 apps/mcp/src/widget/studio/Studio.tsx create mode 100644 apps/mcp/src/widget/studio/main.tsx create mode 100644 apps/mcp/src/widget/studio/mocks.ts create mode 100644 apps/mcp/src/widget/views/Confirmation.tsx create mode 100644 apps/mcp/src/widget/views/Error.tsx create mode 100644 apps/mcp/src/widget/views/Graph.tsx create mode 100644 apps/mcp/src/widget/views/Loading.tsx create mode 100644 apps/mcp/src/widget/views/Picker.tsx create mode 100644 apps/mcp/src/widget/views/Save.tsx create mode 100644 apps/mcp/src/widget/views/Success.tsx create mode 100644 apps/mcp/src/widget/views/Upload.tsx create mode 100644 apps/mcp/tsconfig.widget.json create mode 100644 apps/mcp/vite.config.dev.ts diff --git a/apps/mcp/mcp-app.html b/apps/mcp/mcp-app.html deleted file mode 100644 index 1c1f179f8..000000000 --- a/apps/mcp/mcp-app.html +++ /dev/null @@ -1,95 +0,0 @@ - - - - - - - Memory Graph - - -
- -
-
- Loading memory graph... -
- -
- - - -
- - -
- 100% - - -
-
- - - - - - diff --git a/apps/mcp/package.json b/apps/mcp/package.json index 2def592a7..547f5ad66 100644 --- a/apps/mcp/package.json +++ b/apps/mcp/package.json @@ -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" + } } diff --git a/apps/mcp/src/auth.ts b/apps/mcp/src/auth.ts deleted file mode 100644 index 7bd3b9464..000000000 --- a/apps/mcp/src/auth.ts +++ /dev/null @@ -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 { - 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 { - 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 - } -} diff --git a/apps/mcp/src/format.ts b/apps/mcp/src/format.ts deleted file mode 100644 index cbd074cf9..000000000 --- a/apps/mcp/src/format.ts +++ /dev/null @@ -1,157 +0,0 @@ -export function formatMemories( - response: { results?: Array>; 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 | 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 | undefined | null) => { - if (!m) return "" - const tags = [ - mime(m.mimeType as string | undefined), - m.source as string | undefined, - ...temporal(m.temporalContext as Record | undefined), - ].filter(Boolean) - return [m.title && `"${m.title}"`, tags.length && `(${tags.join(", ")})`] - .filter(Boolean) - .join(" ") - } - - const renderRelations = ( - rels: Array> | undefined, - arrow: string, - root: string, - ) => { - if (!rels?.length) return [] as string[] - const seen = new Set() - 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 | undefined)?.temporalContext as - | Record - | 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> | 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 | null)}`.trimEnd(), - ...renderDocs( - m.documents as Array> | undefined, - ), - ...text.split("\n").map((l: string) => ` ${l}`), - ].join("\n") - } - - const meta = describeMeta(m.metadata as Record | null) - const ctx = (m.context ?? {}) as Record< - string, - Array> - > - return [ - `${prefix}${memory}`, - meta - ? ` Source: ${meta}` - : day(m.updatedAt as string) - ? ` Source: updated ${day(m.updatedAt as string)}` - : null, - ...renderDocs(m.documents as Array> | undefined), - ...arrows.flatMap(([k, a]) => renderRelations(ctx[k], a, memory)), - ] - .filter(Boolean) - .join("\n") - }) - - return [header, "", blocks.join("\n\n")].join("\n") -} diff --git a/apps/mcp/src/posthog.ts b/apps/mcp/src/posthog.ts deleted file mode 100644 index d1c949415..000000000 --- a/apps/mcp/src/posthog.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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 { - if (instance) { - await instance.shutdown() - instance = null - initialized = false - } -} - -export const posthog = { - init: initPosthog, - memoryAdded, - memorySearch, - memoryForgot, - shutdown, -} diff --git a/apps/mcp/src/server.ts b/apps/mcp/src/server.ts deleted file mode 100644 index de54deff2..000000000 --- a/apps/mcp/src/server.ts +++ /dev/null @@ -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 { - 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 - type MemoryArgs = z.infer - type RecallArgs = z.infer - - // 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 - - 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 - >, - 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 { - 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 { - 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(", ")}` - } -} diff --git a/apps/mcp/src/server/agent.ts b/apps/mcp/src/server/agent.ts new file mode 100644 index 000000000..37ed889da --- /dev/null +++ b/apps/mcp/src/server/agent.ts @@ -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 { + 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: (key: string) => this.ctx.storage.get(key), + put: (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 { + if (explicit) return explicit + const activeTag = await this.ctx.storage.get("activeContainerTag") + return activeTag || this.props?.containerTag + } + + private async refreshContainerTags(): Promise { + 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) + } + } +} diff --git a/apps/mcp/src/server/auth/cache.ts b/apps/mcp/src/server/auth/cache.ts new file mode 100644 index 000000000..ae8abf64e --- /dev/null +++ b/apps/mcp/src/server/auth/cache.ts @@ -0,0 +1,111 @@ +import type { AuthUser, ContainerTagAccess } from "." + +// ── Key format ──────────────────────────────────────────────────────── +// ::v: +// +// 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:`) +// - 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 { + 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 + 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 + 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 + 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 { + const key = cacheKey(await tokenHash(token)) + // kv.get(key, "json") returns null on missing OR unparseable JSON. + const cached = await kv.get(key, "json") + if (!isValidCached(cached)) return null + return cached.user +} + +export async function putCachedAuth( + kv: KVNamespace, + token: string, + user: AuthUser, +): Promise { + // 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 } diff --git a/apps/mcp/src/server/auth/index.ts b/apps/mcp/src/server/auth/index.ts new file mode 100644 index 000000000..a7fc5b246 --- /dev/null +++ b/apps/mcp/src/server/auth/index.ts @@ -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 { + 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 { + 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 + } +} diff --git a/apps/mcp/src/server/auth/rbac.ts b/apps/mcp/src/server/auth/rbac.ts new file mode 100644 index 000000000..d90ac6c7c --- /dev/null +++ b/apps/mcp/src/server/auth/rbac.ts @@ -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, + } +} diff --git a/apps/mcp/src/client.ts b/apps/mcp/src/server/client/index.ts similarity index 57% rename from apps/mcp/src/client.ts rename to apps/mcp/src/server/client/index.ts index ee35fcf1a..c90ef3d35 100644 --- a/apps/mcp/src/client.ts +++ b/apps/mcp/src/server/client/index.ts @@ -1,31 +1,36 @@ import Supermemory from "supermemory" - -const MAX_CHARS = 200000 // ~50k tokens (character-based limit) +import type { + ContainerTag, + DocumentMemoryEntry, + DocumentsApiResponse, + DocumentWithMemories, +} from "../../shared/types" + +const MAX_CHARS = 200000 const DEFAULT_PROJECT_ID = "sm_project_default" -interface MemoryRichFields { - metadata?: Record | null - updatedAt?: string - context?: Record - documents?: Array> - 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 | null - updatedAt?: string - context?: Record - documents?: Array> - 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).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 { 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 { 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 { + async listContainerTags(): Promise { 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 { 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)}`) } } diff --git a/apps/mcp/src/html.d.ts b/apps/mcp/src/server/html.d.ts similarity index 100% rename from apps/mcp/src/html.d.ts rename to apps/mcp/src/server/html.d.ts diff --git a/apps/mcp/src/index.ts b/apps/mcp/src/server/index.ts similarity index 52% rename from apps/mcp/src/index.ts rename to apps/mcp/src/server/index.ts index 846064e1f..6ad8f1852 100644 --- a/apps/mcp/src/index.ts +++ b/apps/mcp/src/server/index.ts @@ -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 { + 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 diff --git a/apps/mcp/src/server/prompts/context.ts b/apps/mcp/src/server/prompts/context.ts new file mode 100644 index 000000000..8921418f5 --- /dev/null +++ b/apps/mcp/src/server/prompts/context.ts @@ -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, +) { + const containerTagField: Record = + 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.", + }, + }, + ], + } + } + }, + ) +} diff --git a/apps/mcp/src/server/resources/container-tags.ts b/apps/mcp/src/server/resources/container-tags.ts new file mode 100644 index 000000000..d45fbe0c3 --- /dev/null +++ b/apps/mcp/src/server/resources/container-tags.ts @@ -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), + }, + ], + } + }, + ) +} diff --git a/apps/mcp/src/server/resources/profile.ts b/apps/mcp/src/server/resources/profile.ts new file mode 100644 index 000000000..06ddbb4b7 --- /dev/null +++ b/apps/mcp/src/server/resources/profile.ts @@ -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.", + }, + ], + } + }, + ) +} diff --git a/apps/mcp/src/server/resources/widget.ts b/apps/mcp/src/server/resources/widget.ts new file mode 100644 index 000000000..c0e426f0c --- /dev/null +++ b/apps/mcp/src/server/resources/widget.ts @@ -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 }, + }, + ], + }), + ) +} diff --git a/apps/mcp/src/server/tools/add-memory.ts b/apps/mcp/src/server/tools/add-memory.ts new file mode 100644 index 000000000..af4648e6a --- /dev/null +++ b/apps/mcp/src/server/tools/add-memory.ts @@ -0,0 +1,69 @@ +import { z } from "zod" +import type { ToolDeps } from "./types" + +export function register(deps: ToolDeps) { + const containerTagField: Record = 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) + } + }, + ) +} diff --git a/apps/mcp/src/server/tools/fetch-graph-data.ts b/apps/mcp/src/server/tools/fetch-graph-data.ts new file mode 100644 index 000000000..487de62e6 --- /dev/null +++ b/apps/mcp/src/server/tools/fetch-graph-data.ts @@ -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) + } + }, + ) +} diff --git a/apps/mcp/src/server/tools/guided-save.ts b/apps/mcp/src/server/tools/guided-save.ts new file mode 100644 index 000000000..87a105fc3 --- /dev/null +++ b/apps/mcp/src/server/tools/guided-save.ts @@ -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("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, + } + }, + ) +} diff --git a/apps/mcp/src/server/tools/index.ts b/apps/mcp/src/server/tools/index.ts new file mode 100644 index 000000000..6c510400b --- /dev/null +++ b/apps/mcp/src/server/tools/index.ts @@ -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) + } +} diff --git a/apps/mcp/src/server/tools/list-container-tags.ts b/apps/mcp/src/server/tools/list-container-tags.ts new file mode 100644 index 000000000..0ec2cf4e9 --- /dev/null +++ b/apps/mcp/src/server/tools/list-container-tags.ts @@ -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) + } + }, + ) +} diff --git a/apps/mcp/src/server/tools/memory-graph.ts b/apps/mcp/src/server/tools/memory-graph.ts new file mode 100644 index 000000000..fd9a6fd41 --- /dev/null +++ b/apps/mcp/src/server/tools/memory-graph.ts @@ -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 = 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) + } + }, + ) +} diff --git a/apps/mcp/src/server/tools/save-memory.ts b/apps/mcp/src/server/tools/save-memory.ts new file mode 100644 index 000000000..732d8183e --- /dev/null +++ b/apps/mcp/src/server/tools/save-memory.ts @@ -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) + } + }, + ) +} diff --git a/apps/mcp/src/server/tools/search-memory.ts b/apps/mcp/src/server/tools/search-memory.ts new file mode 100644 index 000000000..0c7ca4913 --- /dev/null +++ b/apps/mcp/src/server/tools/search-memory.ts @@ -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 = 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) + } + }, + ) +} diff --git a/apps/mcp/src/server/tools/select-workspace.ts b/apps/mcp/src/server/tools/select-workspace.ts new file mode 100644 index 000000000..4dcce8dae --- /dev/null +++ b/apps/mcp/src/server/tools/select-workspace.ts @@ -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("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) + } + }, + ) +} diff --git a/apps/mcp/src/server/tools/set-active-tag.ts b/apps/mcp/src/server/tools/set-active-tag.ts new file mode 100644 index 000000000..242f6dee2 --- /dev/null +++ b/apps/mcp/src/server/tools/set-active-tag.ts @@ -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, + } + }, + ) +} diff --git a/apps/mcp/src/server/tools/types.ts b/apps/mcp/src/server/tools/types.ts new file mode 100644 index 000000000..eb005b0c6 --- /dev/null +++ b/apps/mcp/src/server/tools/types.ts @@ -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 + storage: { + get: (key: string) => Promise + put: (key: string, value: T) => Promise + } + cachedContainerTags: () => string[] + refreshContainerTags: () => Promise + 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, + } +} diff --git a/apps/mcp/src/server/tools/upload-file-submit.ts b/apps/mcp/src/server/tools/upload-file-submit.ts new file mode 100644 index 000000000..c4350697e --- /dev/null +++ b/apps/mcp/src/server/tools/upload-file-submit.ts @@ -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) + } + }, + ) +} diff --git a/apps/mcp/src/server/tools/upload-file.ts b/apps/mcp/src/server/tools/upload-file.ts new file mode 100644 index 000000000..90bc48962 --- /dev/null +++ b/apps/mcp/src/server/tools/upload-file.ts @@ -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("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, + } + }, + ) +} diff --git a/apps/mcp/src/server/tools/who-am-i.ts b/apps/mcp/src/server/tools/who-am-i.ts new file mode 100644 index 000000000..1b69a4889 --- /dev/null +++ b/apps/mcp/src/server/tools/who-am-i.ts @@ -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("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(), + }), + }, + ], + } + }, + ) +} diff --git a/apps/mcp/src/shared/types.ts b/apps/mcp/src/shared/types.ts new file mode 100644 index 000000000..82e17c641 --- /dev/null +++ b/apps/mcp/src/shared/types.ts @@ -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 + 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" diff --git a/apps/mcp/src/ui/global.css b/apps/mcp/src/ui/global.css deleted file mode 100644 index d7781593c..000000000 --- a/apps/mcp/src/ui/global.css +++ /dev/null @@ -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); -} diff --git a/apps/mcp/src/ui/mcp-app.css b/apps/mcp/src/ui/mcp-app.css deleted file mode 100644 index 65ea3de02..000000000 --- a/apps/mcp/src/ui/mcp-app.css +++ /dev/null @@ -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; -} diff --git a/apps/mcp/src/ui/mcp-app.ts b/apps/mcp/src/ui/mcp-app.ts deleted file mode 100644 index 4c0c7d344..000000000 --- a/apps/mcp/src/ui/mcp-app.ts +++ /dev/null @@ -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 | 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 = { - derives: 0.4, - updates: 0.7, - extends: 0.55, -} -const EDGE_WIDTH: Record = { - 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() - 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 = {} - 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(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) -}) diff --git a/apps/mcp/src/widget/App.tsx b/apps/mcp/src/widget/App.tsx new file mode 100644 index 000000000..52d16d691 --- /dev/null +++ b/apps/mcp/src/widget/App.tsx @@ -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 + if (state.kind === "error") return + if (state.kind === "raw") { + return ( + + ) + } + + return renderView(state.message, setView, setError) +} + +function renderView( + msg: ViewMessage, + setView: (m: ViewMessage) => void, + setError: (m: string) => void, +) { + switch (msg.view) { + case "picker": + return ( + + ) + case "save": + return ( + + ) + case "upload": + return ( + + ) + case "graph": + return ( + + ) + case "confirmation": + return + case "save-success": + return + case "upload-success": + return ( + + ) + default: { + const exhaustive: never = msg + return ( + + ) + } + } +} diff --git a/apps/mcp/src/widget/ErrorBoundary.tsx b/apps/mcp/src/widget/ErrorBoundary.tsx new file mode 100644 index 000000000..76489c2cb --- /dev/null +++ b/apps/mcp/src/widget/ErrorBoundary.tsx @@ -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 { + 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 ( + +
⚠️
+ +
+ Something went wrong +
+
+ {this.state.error.message} +
+
+ +
+ ) + } +} diff --git a/apps/mcp/src/widget/components/PermissionBadge.tsx b/apps/mcp/src/widget/components/PermissionBadge.tsx new file mode 100644 index 000000000..ba8fc1abc --- /dev/null +++ b/apps/mcp/src/widget/components/PermissionBadge.tsx @@ -0,0 +1,17 @@ +import { Badge } from "../design/ui" + +interface Props { + permission: string +} + +export function PermissionBadge({ permission }: Props) { + const isWrite = permission === "write" + return ( + + {isWrite ? "read/write" : "read only"} + + ) +} diff --git a/apps/mcp/src/widget/components/Spinner.tsx b/apps/mcp/src/widget/components/Spinner.tsx new file mode 100644 index 000000000..39b8dc2d9 --- /dev/null +++ b/apps/mcp/src/widget/components/Spinner.tsx @@ -0,0 +1,3 @@ +export function Spinner() { + return
+} diff --git a/apps/mcp/src/widget/components/WorkspaceCard.tsx b/apps/mcp/src/widget/components/WorkspaceCard.tsx new file mode 100644 index 000000000..cd4122ef4 --- /dev/null +++ b/apps/mcp/src/widget/components/WorkspaceCard.tsx @@ -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 ( + onClick(containerTag.containerTag)} + variant={active ? "active" : "interactive"} + > + + + + {containerTag.emoji ? ( + + {containerTag.emoji} + + ) : ( + + )} + + {name} + + + {active ? : null} + +
+ {containerTag.containerTag} +
+ {(containerTag.documentCount > 0 || containerTag.memoryCount > 0) && ( +
+ + {containerTag.documentCount} doc + {containerTag.documentCount === 1 ? "" : "s"} + + · + + {containerTag.memoryCount} mem + {containerTag.memoryCount === 1 ? "" : "s"} + +
+ )} + {access ? : null} +
+
+ ) +} diff --git a/apps/mcp/src/widget/design/globals.css b/apps/mcp/src/widget/design/globals.css new file mode 100644 index 000000000..8d9991391 --- /dev/null +++ b/apps/mcp/src/widget/design/globals.css @@ -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; +} diff --git a/apps/mcp/src/widget/design/lib/cn.ts b/apps/mcp/src/widget/design/lib/cn.ts new file mode 100644 index 000000000..5e1abdf42 --- /dev/null +++ b/apps/mcp/src/widget/design/lib/cn.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/apps/mcp/src/widget/design/tokens.css b/apps/mcp/src/widget/design/tokens.css new file mode 100644 index 000000000..4fbc6a8fc --- /dev/null +++ b/apps/mcp/src/widget/design/tokens.css @@ -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 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; +} diff --git a/apps/mcp/src/widget/design/ui/ActionGroup.tsx b/apps/mcp/src/widget/design/ui/ActionGroup.tsx new file mode 100644 index 000000000..8db3cdf11 --- /dev/null +++ b/apps/mcp/src/widget/design/ui/ActionGroup.tsx @@ -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 ( +
+ {children} +
+ ) +} diff --git a/apps/mcp/src/widget/design/ui/Badge.tsx b/apps/mcp/src/widget/design/ui/Badge.tsx new file mode 100644 index 000000000..4bbb48fb7 --- /dev/null +++ b/apps/mcp/src/widget/design/ui/Badge.tsx @@ -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, + VariantProps {} + +export function Badge({ className, variant, ...props }: BadgeProps) { + return ( + + ) +} + +export { badgeVariants } diff --git a/apps/mcp/src/widget/design/ui/Button.tsx b/apps/mcp/src/widget/design/ui/Button.tsx new file mode 100644 index 000000000..c641215c8 --- /dev/null +++ b/apps/mcp/src/widget/design/ui/Button.tsx @@ -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, + VariantProps { + shortcut?: ReactNode + brandFont?: boolean + iconLeft?: ReactNode + iconRight?: ReactNode + loading?: boolean +} + +export const Button = forwardRef( + ( + { + className, + variant, + size, + shortcut, + brandFont = true, + iconLeft, + iconRight, + loading = false, + disabled, + children, + ...props + }, + ref, + ) => { + const isDisabled = disabled || loading + + return ( + + ) + }, +) +Button.displayName = "Button" + +export { buttonVariants } diff --git a/apps/mcp/src/widget/design/ui/Card.tsx b/apps/mcp/src/widget/design/ui/Card.tsx new file mode 100644 index 000000000..06a15a663 --- /dev/null +++ b/apps/mcp/src/widget/design/ui/Card.tsx @@ -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 + +type DivProps = HTMLAttributes & + CardVariantProps & { as?: "div" } + +type ButtonElementProps = ButtonHTMLAttributes & + CardVariantProps & { as: "button" } + +export type CardProps = DivProps | ButtonElementProps + +export const Card = forwardRef( + ({ className, variant, as = "div", ...props }, ref) => { + const cls = cn(cardStyles({ variant }), className) + if (as === "button") { + return ( + + ), +) +Chip.displayName = "Chip" diff --git a/apps/mcp/src/widget/design/ui/Field.tsx b/apps/mcp/src/widget/design/ui/Field.tsx new file mode 100644 index 000000000..7402d5304 --- /dev/null +++ b/apps/mcp/src/widget/design/ui/Field.tsx @@ -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 ( +
+ {label ? ( + // biome-ignore lint/a11y/noLabelWithoutControl: generic field wrapper where children provide the input + + ) : null} + {children} + {error ? ( + {error} + ) : hint ? ( + {hint} + ) : null} +
+ ) +} diff --git a/apps/mcp/src/widget/design/ui/FileUpload.tsx b/apps/mcp/src/widget/design/ui/FileUpload.tsx new file mode 100644 index 000000000..753eda681 --- /dev/null +++ b/apps/mcp/src/widget/design/ui/FileUpload.tsx @@ -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) => { + e.preventDefault() + if (disabled) return + setDragOver(false) + const file = e.dataTransfer.files[0] + if (file) onFile(file) + }, + [disabled, onFile], + ) + + const handleInput = useCallback( + (e: ChangeEvent) => { + if (disabled) return + const file = e.target.files?.[0] + if (file) onFile(file) + e.target.value = "" + }, + [disabled, onFile], + ) + + return ( + + ) +} diff --git a/apps/mcp/src/widget/design/ui/Input.tsx b/apps/mcp/src/widget/design/ui/Input.tsx new file mode 100644 index 000000000..3687419a4 --- /dev/null +++ b/apps/mcp/src/widget/design/ui/Input.tsx @@ -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, "size">, + VariantProps { + size?: "sm" | "md" | "lg" +} + +export const Input = forwardRef( + ({ className, size, inputSize, type, ...props }, ref) => { + const resolvedSize = size ?? inputSize ?? "md" + return ( + + ) + }, +) +Input.displayName = "Input" + +export { inputVariants } diff --git a/apps/mcp/src/widget/design/ui/PageHeader.tsx b/apps/mcp/src/widget/design/ui/PageHeader.tsx new file mode 100644 index 000000000..c3b81339b --- /dev/null +++ b/apps/mcp/src/widget/design/ui/PageHeader.tsx @@ -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 ( +
+
+
+

+ {title} +

+ {description ? ( +

+ {description} +

+ ) : null} +
+ {actions ? ( +
+ {actions} +
+ ) : null} +
+ {children} +
+ ) +}) diff --git a/apps/mcp/src/widget/design/ui/Popover.tsx b/apps/mcp/src/widget/design/ui/Popover.tsx new file mode 100644 index 000000000..5ec0ec600 --- /dev/null +++ b/apps/mcp/src/widget/design/ui/Popover.tsx @@ -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 +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + + + +)) +PopoverContent.displayName = "PopoverContent" + +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } diff --git a/apps/mcp/src/widget/design/ui/Stack.tsx b/apps/mcp/src/widget/design/ui/Stack.tsx new file mode 100644 index 000000000..f6ec9f9a1 --- /dev/null +++ b/apps/mcp/src/widget/design/ui/Stack.tsx @@ -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, + VariantProps {} + +export const Stack = forwardRef( + ({ className, direction, gap, align, justify, ...props }, ref) => ( +
+ ), +) +Stack.displayName = "Stack" diff --git a/apps/mcp/src/widget/design/ui/TextArea.tsx b/apps/mcp/src/widget/design/ui/TextArea.tsx new file mode 100644 index 000000000..5cdddc2e3 --- /dev/null +++ b/apps/mcp/src/widget/design/ui/TextArea.tsx @@ -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 {} + +export const TextArea = forwardRef( + ({ className, ...props }, ref) => ( +