diff --git a/apps/mcp/README.md b/apps/mcp/README.md index bcceafbe..1cf39bc1 100644 --- a/apps/mcp/README.md +++ b/apps/mcp/README.md @@ -73,7 +73,7 @@ These tools are available to the embedded MCP App and hidden from the model. | --- | --- | | `set-active-tag` | Persist the selected active space | | `save-memory` | Submit the guided save form | -| `upload-file-submit` | Submit an encoded file upload | +| `prepare-file-upload` | Prepare a secure direct file upload | | `fetch-graph-data` | Fetch graph documents for the app | ## Resources And Prompt diff --git a/apps/mcp/e2e/discovery.test.ts b/apps/mcp/e2e/discovery.test.ts index b9ac670f..3da201c2 100644 --- a/apps/mcp/e2e/discovery.test.ts +++ b/apps/mcp/e2e/discovery.test.ts @@ -16,12 +16,12 @@ const EXPECTED_TOOLS = [ "listMemories", "listSpaces", "memory-graph", + "prepare-file-upload", "save-memory", "search_memory", "select-space", "set-active-tag", "upload-file", - "upload-file-submit", "whoAmI", ] const describeWithAuth = describe.skipIf(!OAUTH_CREDENTIALS_AVAILABLE) diff --git a/apps/mcp/e2e/graph.test.ts b/apps/mcp/e2e/graph.test.ts index 74d76d92..5b9d45a5 100644 --- a/apps/mcp/e2e/graph.test.ts +++ b/apps/mcp/e2e/graph.test.ts @@ -1,5 +1,5 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest" -import { graphResultMetaSchema, graphViewSchema } from "../src/shared/types" +import { graphViewSchema } from "../src/shared/types" import { OAUTH_CREDENTIALS_AVAILABLE, callTool, @@ -29,8 +29,6 @@ describeWithAuth("MCP — graph, resources & prompts", () => { expect(result.data.view).toBe("graph") expect(result.data.rendered).toBe(true) expect(result.data.documentCount).toBe(result.data.documents.length) - const resultMeta = graphResultMetaSchema.safeParse(res._meta) - expect(resultMeta.success).toBe(true) }) it("fetch-graph-data returns paginated documents", async () => { diff --git a/apps/mcp/e2e/list-memories.test.ts b/apps/mcp/e2e/list-memories.test.ts index a4adad83..e171cc9b 100644 --- a/apps/mcp/e2e/list-memories.test.ts +++ b/apps/mcp/e2e/list-memories.test.ts @@ -1,5 +1,9 @@ import { randomUUID } from "node:crypto" import { afterAll, beforeAll, describe, expect, it } from "vitest" +import { + uploadPreparationSchema, + uploadResponseSchema, +} from "../src/shared/types" import { OAUTH_CREDENTIALS_AVAILABLE, callTool, @@ -153,29 +157,34 @@ describe.skipIf(!OAUTH_CREDENTIALS_AVAILABLE)( throw new Error("Upload did not provide a writable space") } - const uploaded = await callTool(session.client, "upload-file-submit", { - fileData: Buffer.from(fileContent).toString("base64"), - fileName, - mimeType: "text/plain", - containerTag, - viewId: launcherView.viewId, + const prepared = await callTool(session.client, "prepare-file-upload") + expect(prepared.isError).toBeFalsy() + const preparation = uploadPreparationSchema.parse( + prepared.structuredContent, + ) + const formData = new FormData() + formData.append("file", new Blob([fileContent]), fileName) + formData.append("containerTag", containerTag) + formData.append( + "metadata", + JSON.stringify({ sm_source: "supermemory-mcp" }), + ) + + const response = await fetch(preparation.uploadUrl, { + method: "POST", + headers: { + Authorization: `Bearer ${preparation.uploadToken}`, + }, + body: formData, }) - expect(uploaded.isError).toBeFalsy() - const uploadedView = uploaded.structuredContent as AppView - expect(uploadedView).toMatchObject({ - view: "upload-success", - fileName, - containerTag, - }) - expect(uploadedView.id).toBeTruthy() - if (!uploadedView.id) - throw new Error("Upload did not return a document ID") + expect(response.ok).toBe(true) + const uploaded = uploadResponseSchema.parse(await response.json()) const document = await waitForToolText( session, "getDocument", - { documentId: uploadedView.id }, - `Document ID: ${uploadedView.id}`, + { documentId: uploaded.id }, + `Document ID: ${uploaded.id}`, 20, 1000, ) diff --git a/apps/mcp/src/format.test.ts b/apps/mcp/src/format.test.ts deleted file mode 100644 index 097946b2..00000000 --- a/apps/mcp/src/format.test.ts +++ /dev/null @@ -1,219 +0,0 @@ -import { describe, expect, it } from "vitest" -import type { - DocumentDetails, - DocumentsListResponse, - MemoryEntriesResponse, -} from "./server/client" -import { - formatDocument, - formatDocumentsList, - formatMemoryEntriesList, -} from "./server/format" - -function makeDocumentsResponse( - overrides: Partial = {}, -): DocumentsListResponse { - return { - documents: [], - pagination: { currentPage: 1, limit: 10, totalItems: 0, totalPages: 1 }, - ...overrides, - } -} - -function makeMemoryResponse( - overrides: Partial = {}, -): MemoryEntriesResponse { - return { - memoryEntries: [], - pagination: { currentPage: 1, limit: 10, totalItems: 0, totalPages: 1 }, - ...overrides, - } -} - -function makeMemory(memory: string, extra: Record = {}) { - return { - id: `mem_${memory.slice(0, 8)}`, - memory, - version: 1, - isLatest: true, - isForgotten: false, - createdAt: "2026-06-10T12:00:00Z", - updatedAt: "2026-06-10T12:00:00Z", - ...extra, - } -} - -describe("formatDocumentsList", () => { - it("reports an empty document store", () => { - expect(formatDocumentsList(makeDocumentsResponse())).toBe( - "No documents stored yet.", - ) - }) - - it("formats document metadata and stable IDs without content", () => { - const result = formatDocumentsList( - makeDocumentsResponse({ - documents: [ - { - id: "doc_1", - connectionId: null, - createdAt: "2026-06-12T08:00:00Z", - customId: null, - metadata: null, - status: "done", - summary: "A compact summary.", - title: "Preferences", - type: "text", - updatedAt: "2026-06-12T08:00:00Z", - }, - ], - pagination: { currentPage: 1, limit: 10, totalItems: 1, totalPages: 1 }, - }), - ) - - expect(result).toContain( - "1 document (page 1 of 1, 1 document total), newest first.", - ) - expect(result).toContain('- [doc_1] "Preferences" (text, done, 2026-06-12)') - expect(result).toContain("Summary: A compact summary.") - expect(result).toContain( - "Use getDocument with a document ID to read its content.", - ) - }) - - it("points to the next document page", () => { - const result = formatDocumentsList( - makeDocumentsResponse({ - documents: [ - { - id: "doc_1", - connectionId: null, - createdAt: "2026-06-12T08:00:00Z", - customId: null, - metadata: null, - status: "done", - summary: null, - title: null, - type: "text", - updatedAt: "2026-06-12T08:00:00Z", - }, - ], - pagination: { currentPage: 1, limit: 1, totalItems: 3, totalPages: 3 }, - }), - ) - - expect(result).toContain('"(untitled)"') - expect(result).toContain( - "More available - call listDocuments with page: 2.", - ) - }) -}) - -describe("formatMemoryEntriesList", () => { - it("reports an empty memory store", () => { - expect(formatMemoryEntriesList(makeMemoryResponse())).toBe( - "No active memories stored yet.", - ) - }) - - it("formats active memories independently of documents", () => { - const result = formatMemoryEntriesList( - makeMemoryResponse({ - memoryEntries: [ - makeMemory("User prefers dark mode", { - id: "mem_1", - version: 2, - documentIds: ["doc_1", "doc_2"], - history: [ - { - id: "mem_old", - memory: "User sometimes uses dark mode", - version: 1, - createdAt: "2026-06-01T00:00:00Z", - updatedAt: "2026-06-01T00:00:00Z", - }, - ], - }), - ], - pagination: { currentPage: 1, limit: 10, totalItems: 1, totalPages: 1 }, - }), - ) - - expect(result).toContain( - "1 active memory (page 1 of 1, 1 memory entry total), newest first.", - ) - expect(result).toContain("- [mem_1] User prefers dark mode") - expect(result).toContain( - "version 2 | updated 2026-06-10 | 1 previous version", - ) - expect(result).toContain("Source documents: doc_1, doc_2") - }) - - it("excludes forgotten and superseded entries", () => { - const result = formatMemoryEntriesList( - makeMemoryResponse({ - memoryEntries: [ - makeMemory("Current fact"), - makeMemory("Forgotten fact", { isForgotten: true }), - makeMemory("Old fact", { isLatest: false }), - ], - pagination: { currentPage: 1, limit: 10, totalItems: 3, totalPages: 1 }, - }), - ) - - expect(result).toContain("Current fact") - expect(result).not.toContain("Forgotten fact") - expect(result).not.toContain("Old fact") - }) - - it("flattens and truncates oversized memory text", () => { - const result = formatMemoryEntriesList( - makeMemoryResponse({ - memoryEntries: [ - makeMemory("line one\nline two"), - makeMemory(`start ${"x".repeat(600)}`), - ], - pagination: { currentPage: 1, limit: 10, totalItems: 2, totalPages: 1 }, - }), - ) - - expect(result).toContain("line one line two") - expect(result).toContain("... [truncated]") - }) -}) - -describe("formatDocument", () => { - const document: DocumentDetails = { - id: "doc_1", - connectionId: null, - content: "Original input", - createdAt: "2026-06-12T08:00:00Z", - customId: null, - metadata: null, - ogImage: null, - raw: "Full extracted document text", - source: "text", - spatialPoint: null, - status: "done", - summary: "A compact summary.", - title: "Preferences", - type: "text", - updatedAt: "2026-06-12T09:00:00Z", - url: null, - } - - it("returns document metadata, summary, and full available content", () => { - const result = formatDocument(document) - - expect(result).toContain("# Preferences") - expect(result).toContain("Document ID: doc_1") - expect(result).toContain("## Summary\nA compact summary.") - expect(result).toContain("## Content\nFull extracted document text") - expect(result).not.toContain("Original input") - }) - - it("falls back to the original content when raw content is absent", () => { - const result = formatDocument({ ...document, raw: null }) - expect(result).toContain("## Content\nOriginal input") - }) -}) diff --git a/apps/mcp/src/server/analytics.ts b/apps/mcp/src/server/analytics.ts index 6e7fd548..aac932d1 100644 --- a/apps/mcp/src/server/analytics.ts +++ b/apps/mcp/src/server/analytics.ts @@ -46,7 +46,7 @@ const TOOL_SURFACES: Record = { "upload-file": "app_launcher", "set-active-tag": "app_action", "save-memory": "app_action", - "upload-file-submit": "app_action", + "prepare-file-upload": "app_action", "fetch-graph-data": "app_internal", } diff --git a/apps/mcp/src/server/app-metadata.test.ts b/apps/mcp/src/server/app-metadata.test.ts deleted file mode 100644 index 74ab25dc..00000000 --- a/apps/mcp/src/server/app-metadata.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, expect, it } from "vitest" -import { - appResultMeta, - appToolMeta, - SUPERMEMORY_RESOURCE_URI, -} from "./app-metadata" - -describe("MCP Apps metadata compatibility", () => { - it("advertises both current and legacy resource URI metadata", () => { - expect(SUPERMEMORY_RESOURCE_URI).toMatch( - /^ui:\/\/supermemory\/app-[a-f0-9]{64}\.html$/, - ) - expect(appToolMeta()).toEqual({ - ui: { resourceUri: SUPERMEMORY_RESOURCE_URI }, - "ui/resourceUri": SUPERMEMORY_RESOURCE_URI, - }) - }) - - it("keeps App-only tools hidden from the model", () => { - expect(appToolMeta(["app"])).toMatchObject({ - ui: { - resourceUri: SUPERMEMORY_RESOURCE_URI, - visibility: ["app"], - }, - }) - }) - - it("provides a stable ChatGPT widget-state key", () => { - expect(appResultMeta("view-123")).toEqual({ - "openai/widgetSessionId": "view-123", - }) - }) -}) diff --git a/apps/mcp/src/server/client/index.test.ts b/apps/mcp/src/server/client/index.test.ts deleted file mode 100644 index 19c2de2d..00000000 --- a/apps/mcp/src/server/client/index.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest" -import { SupermemoryClient } from "." - -describe("SupermemoryClient memory listing", () => { - afterEach(() => { - vi.unstubAllGlobals() - }) - - it("calls the canonical memory-list endpoint with the selected space", async () => { - const responseBody = { - memoryEntries: [ - { - id: "mem_1", - memory: "User prefers dark mode", - version: 1, - isLatest: true, - isForgotten: false, - createdAt: "2026-07-29T00:00:00.000Z", - updatedAt: "2026-07-29T00:00:00.000Z", - history: [], - documentIds: ["doc_1"], - }, - ], - pagination: { - currentPage: 2, - limit: 20, - totalItems: 21, - totalPages: 2, - }, - } - const fetchMock = vi.fn().mockResolvedValue( - new Response(JSON.stringify(responseBody), { - status: 200, - headers: { "Content-Type": "application/json" }, - }), - ) - vi.stubGlobal("fetch", fetchMock) - - const client = new SupermemoryClient( - "oauth-token", - "snowcone_grande", - "https://api.example.com", - ) - await expect(client.listMemoryEntries(2, 20)).resolves.toEqual(responseBody) - - expect(fetchMock).toHaveBeenCalledOnce() - const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit] - expect(url).toBe("https://api.example.com/v4/memories/list") - expect(init.method).toBe("POST") - expect(init.headers).toMatchObject({ - Authorization: "Bearer oauth-token", - "Content-Type": "application/json", - "x-sm-source": "supermemory-mcp", - }) - expect(JSON.parse(init.body as string)).toEqual({ - containerTags: ["snowcone_grande"], - page: 2, - limit: 20, - sort: "createdAt", - order: "desc", - }) - }) -}) diff --git a/apps/mcp/src/server/client/index.ts b/apps/mcp/src/server/client/index.ts index d3c7bc54..cc45d438 100644 --- a/apps/mcp/src/server/client/index.ts +++ b/apps/mcp/src/server/client/index.ts @@ -122,11 +122,6 @@ const sdkResultSchema = z.looseObject({ context: z.string().nullish(), }) -const uploadResultSchema = z.object({ - id: z.string(), - status: z.string(), -}) - function mapSdkResults(value: unknown): Memory[] { return z .array(sdkResultSchema) @@ -450,43 +445,6 @@ 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: MCP_SOURCE })) - - const response = await fetch(`${this.apiUrl}/v3/documents/file`, { - method: "POST", - headers: { - Authorization: `Bearer ${this.bearerToken}`, - "x-sm-source": MCP_SOURCE, - }, - body: formData, - }) - - if (!response.ok) { - const text = await response.text() - throw Object.assign(new Error(text || "Upload failed"), { - status: response.status, - }) - } - - return uploadResultSchema.parse(await response.json()) - } catch (error) { - this.handleError(error) - } - } - private handleError(error: unknown): never { // Handle request timeout (AbortSignal.timeout or explicit abort) if ( diff --git a/apps/mcp/src/server/index.ts b/apps/mcp/src/server/index.ts index dafdc9bc..f435ea3a 100644 --- a/apps/mcp/src/server/index.ts +++ b/apps/mcp/src/server/index.ts @@ -6,7 +6,7 @@ import { validateOAuthToken, type AuthUser } from "./auth" import { SupermemoryMCP } from "./legacy-protocol-state" import { createSupermemoryServer } from "./server" import type { ActorContext, ServerEnv } from "./types" -import { SpaceState } from "./space-state" +import { SpaceState, uploadStateName } from "./space-state" type Bindings = ServerEnv @@ -16,6 +16,8 @@ const DEFAULT_API_URL = "https://api.supermemory.ai" const DEFAULT_MCP_RESOURCE = "https://mcp.supermemory.ai/mcp" const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource/mcp" +const UPLOAD_ID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i const DEFAULT_ALLOWED_ORIGIN_HOSTNAMES = [ "app.supermemory.ai", "mcp.supermemory.ai", @@ -170,6 +172,7 @@ async function handleMcpRequest( const resourceMetadataUrl = reqHost ? `${reqProto}://${reqHost}${PROTECTED_RESOURCE_METADATA_PATH}` : PROTECTED_RESOURCE_METADATA_PATH + const mcpOrigin = c.env.MCP_PUBLIC_ORIGIN || new URL(mcpResource).origin if (!token) return unauthorizedResponse(resourceMetadataUrl) @@ -187,8 +190,11 @@ async function handleMcpRequest( : c.req.raw const handler = createMcpHandler( () => - createSupermemoryServer(c.env, actor, (promise) => - c.executionCtx.waitUntil(promise), + createSupermemoryServer( + c.env, + actor, + (promise) => c.executionCtx.waitUntil(promise), + mcpOrigin, ), { route: "/mcp", @@ -204,6 +210,56 @@ async function handleMcpRequest( }) } +app.post("/upload/:uploadId", async (c) => { + const uploadId = c.req.param("uploadId") + const contentType = c.req.header("Content-Type") + const authHeader = c.req.header("Authorization") + const uploadToken = authHeader?.replace(/^Bearer\s+/i, "").trim() + + if (!UPLOAD_ID_PATTERN.test(uploadId) || !uploadToken) { + return c.json({ error: "Invalid or expired upload session" }, 401) + } + if (!contentType?.toLowerCase().startsWith("multipart/form-data;")) { + return c.json({ error: "Expected multipart form data" }, 415) + } + if (!c.req.raw.body) { + return c.json({ error: "File upload body is required" }, 400) + } + + const uploadState = c.env.SPACE_STATE.getByName(uploadStateName(uploadId)) + const session = await uploadState.consumeUploadSession(uploadToken) + if (!session) { + return c.json({ error: "Invalid or expired upload session" }, 401) + } + + const apiUrl = (c.env.API_URL || DEFAULT_API_URL).replace(/\/+$/, "") + try { + const response = await fetch(`${apiUrl}/v3/documents/file`, { + method: "POST", + headers: { + Authorization: `Bearer ${session.bearerToken}`, + "Content-Type": contentType, + "x-sm-source": "supermemory-mcp", + }, + body: c.req.raw.body, + signal: c.req.raw.signal, + }) + const headers = new Headers({ "Cache-Control": "no-store" }) + const responseContentType = response.headers.get("Content-Type") + const retryAfter = response.headers.get("Retry-After") + if (responseContentType) headers.set("Content-Type", responseContentType) + if (retryAfter) headers.set("Retry-After", retryAfter) + + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }) + } catch { + return c.json({ error: "File upload failed" }, 502) + } +}) + app.all("/", (c) => handleMcpRequest(c, "/mcp")) app.all("/mcp", (c) => handleMcpRequest(c)) app.all("/mcp/", (c) => handleMcpRequest(c, "/mcp")) diff --git a/apps/mcp/src/server/resources/widget.ts b/apps/mcp/src/server/resources/widget.ts index ed817318..e8fb3eb4 100644 --- a/apps/mcp/src/server/resources/widget.ts +++ b/apps/mcp/src/server/resources/widget.ts @@ -1,4 +1,4 @@ -import type { McpServer } from "@modelcontextprotocol/server" +import { ResourceTemplate, type McpServer } from "@modelcontextprotocol/server" import supermemoryAppHtml from "../../../dist/src/widget/index.html" import { APP_RESOURCE_MIME_TYPE, @@ -6,33 +6,42 @@ import { } from "../app-metadata" import { WIDGET_DESCRIPTION, - WIDGET_RESOURCE_META, + widgetResourceMeta, } from "../widget-resource-metadata" -export function registerWidgetResource(server: McpServer) { +export function registerWidgetResource( + server: McpServer, + connectOrigin: string, +) { + const resourceMeta = widgetResourceMeta(connectOrigin) + const resourceConfig = { + mimeType: APP_RESOURCE_MIME_TYPE, + description: WIDGET_DESCRIPTION, + _meta: resourceMeta, + } + const readWidgetResource = (uri: string) => ({ + contents: [ + { + uri, + mimeType: APP_RESOURCE_MIME_TYPE, + text: supermemoryAppHtml, + _meta: resourceMeta, + }, + ], + }) + server.registerResource( "Supermemory MCP UI", SUPERMEMORY_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: APP_RESOURCE_MIME_TYPE, - description: WIDGET_DESCRIPTION, - _meta: WIDGET_RESOURCE_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: SUPERMEMORY_RESOURCE_URI, - mimeType: APP_RESOURCE_MIME_TYPE, - text: supermemoryAppHtml, - _meta: WIDGET_RESOURCE_META, - }, - ], + resourceConfig, + async () => readWidgetResource(SUPERMEMORY_RESOURCE_URI), + ) + server.registerResource( + "Supermemory MCP UI compatibility", + new ResourceTemplate("ui://supermemory/app-{version}.html", { + list: undefined, }), + resourceConfig, + async (uri) => readWidgetResource(uri.href), ) } diff --git a/apps/mcp/src/server/server.ts b/apps/mcp/src/server/server.ts index cfeb7a64..432bddd3 100644 --- a/apps/mcp/src/server/server.ts +++ b/apps/mcp/src/server/server.ts @@ -21,8 +21,10 @@ import { resolveContainerTag as resolveSpaceContainerTag, spaceStateName, } from "./space" +import { uploadStateName } from "./space-state" const DEFAULT_API_URL = "https://api.supermemory.ai" +const UPLOAD_SESSION_TTL_MS = 2 * 60 * 1000 const SERVER_INSTRUCTIONS = "Supermemory is the authenticated user's persistent memory and knowledge layer across conversations and spaces. Use these tools whenever the user wants to recall something they may have saved, inspect stored sources or extracted memories, remember or upload new information, check their Supermemory account or access, change their active space, or explore their memory graph, even if they do not mention Supermemory by name. Use the active or account-default space when none is named. Resolve a named space with listSpaces and pass its key to the relevant tool; change the active space only when the user explicitly asks." @@ -48,6 +50,7 @@ export function createSupermemoryServer( env: ServerEnv, actor: ActorContext, waitUntil: WaitUntil, + mcpOrigin: string, ): McpServer { const server = new McpServer( { @@ -68,6 +71,23 @@ export function createSupermemoryServer( resolveSpaceContainerTag(explicit, getActiveContainerTag) const resolveContainerTag = async (explicit?: string) => (await resolveSelectedContainerTag(explicit)) ?? DEFAULT_PROJECT_ID + const createUploadSession = async () => { + const uploadId = crypto.randomUUID() + const uploadToken = [crypto.randomUUID(), crypto.randomUUID()] + .join("") + .replaceAll("-", "") + const expiresAt = Date.now() + UPLOAD_SESSION_TTL_MS + const uploadState = env.SPACE_STATE.getByName(uploadStateName(uploadId)) + await uploadState.createUploadSession(uploadToken, { + bearerToken: actor.bearerToken, + expiresAt, + }) + return { + uploadUrl: new URL(`/upload/${uploadId}`, mcpOrigin).toString(), + uploadToken, + expiresAt, + } + } const analytics = createPosthogAnalytics(env, actor, waitUntil) const toolServer = createTrackedToolServer( server, @@ -83,6 +103,7 @@ export function createSupermemoryServer( resolveContainerTag, getActiveContainerTag, setActiveContainerTag, + createUploadSession, getClientInfo: clientInfoFromContext, errorResult, }) @@ -93,7 +114,7 @@ export function createSupermemoryServer( () => getClient(), resolveSelectedContainerTag, ) - registerWidgetResource(server) + registerWidgetResource(server, mcpOrigin) registerContextPrompt(server, getClient, resolveSelectedContainerTag) return server diff --git a/apps/mcp/src/server/space-presentation.test.ts b/apps/mcp/src/server/space-presentation.test.ts deleted file mode 100644 index c57fe9d1..00000000 --- a/apps/mcp/src/server/space-presentation.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { describe, expect, it } from "vitest" -import type { ContainerTag } from "../shared/types" -import { - compactDescription, - formatFactSection, - formatSpaceRow, - sortSpaces, -} from "./space-presentation" - -const space = ( - containerTag: string, - lastActivityAt: string | null, -): ContainerTag => ({ - id: containerTag, - name: `Space ${containerTag}`, - containerTag, - description: "A compact space description.", - visibility: "private", - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - isExperimental: false, - isNova: false, - documentCount: 2, - memoryCount: 3, - lastActivityAt, -}) - -describe("space presentation", () => { - it("keeps the active space first, then sorts by activity", () => { - const sorted = sortSpaces( - [ - space("older", "2026-01-01T00:00:00.000Z"), - space("active", "2025-01-01T00:00:00.000Z"), - space("newer", "2026-02-01T00:00:00.000Z"), - ], - "active", - ) - - expect(sorted.map((item) => item.containerTag)).toEqual([ - "active", - "newer", - "older", - ]) - }) - - it("formats compact rows without internal database IDs", () => { - const row = formatSpaceRow( - space("project-key", "2026-07-29T19:44:28.177Z"), - "project-key", - ) - - expect(row).toContain("[project-key] · Active") - expect(row).toContain("2 documents · 3 memories") - expect(row).toContain("Last active Jul 29, 2026") - expect(row).not.toContain('"id"') - }) - - it("caps descriptions and fact lists", () => { - expect(compactDescription("A".repeat(30), 12)).toBe("AAAAAAAAA...") - expect( - formatFactSection("Recent Context", ["one", "two", "three"], 2), - ).toEqual(["## Recent Context", "- one", "- two", "- +1 more"]) - }) -}) diff --git a/apps/mcp/src/server/space-state.ts b/apps/mcp/src/server/space-state.ts index 138bd656..6caf5200 100644 --- a/apps/mcp/src/server/space-state.ts +++ b/apps/mcp/src/server/space-state.ts @@ -2,6 +2,26 @@ import { DurableObject } from "cloudflare:workers" import { containerTagSchema } from "./container-tag" const ACTIVE_CONTAINER_TAG_KEY = "activeContainerTag" +const UPLOAD_SESSION_KEY = "uploadSession" + +export interface UploadSession { + bearerToken: string + expiresAt: number +} + +async function hashUploadToken(token: string): Promise { + const digest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(token), + ) + return Array.from(new Uint8Array(digest), (byte) => + byte.toString(16).padStart(2, "0"), + ).join("") +} + +export function uploadStateName(uploadId: string): string { + return `upload:${uploadId}` +} export class SpaceState extends DurableObject { async getActiveContainerTag(): Promise { @@ -12,4 +32,42 @@ export class SpaceState extends DurableObject { const validatedTag = containerTagSchema.parse(containerTag) await this.ctx.storage.put(ACTIVE_CONTAINER_TAG_KEY, validatedTag) } + + async createUploadSession( + token: string, + session: UploadSession, + ): Promise { + await this.ctx.storage.put(UPLOAD_SESSION_KEY, { + ...session, + tokenHash: await hashUploadToken(token), + }) + await this.ctx.storage.setAlarm(session.expiresAt) + } + + async consumeUploadSession( + token: string, + ): Promise { + const tokenHash = await hashUploadToken(token) + return this.ctx.storage.transaction(async (transaction) => { + const session = await transaction.get< + UploadSession & { tokenHash: string } + >(UPLOAD_SESSION_KEY) + if (!session) return undefined + if (session.expiresAt <= Date.now()) { + await transaction.delete(UPLOAD_SESSION_KEY) + return undefined + } + if (session.tokenHash !== tokenHash) return undefined + + await transaction.delete(UPLOAD_SESSION_KEY) + return { + bearerToken: session.bearerToken, + expiresAt: session.expiresAt, + } + }) + } + + async alarm(): Promise { + await this.ctx.storage.delete(UPLOAD_SESSION_KEY) + } } diff --git a/apps/mcp/src/server/tools/index.ts b/apps/mcp/src/server/tools/index.ts index 869016c2..6effe28d 100644 --- a/apps/mcp/src/server/tools/index.ts +++ b/apps/mcp/src/server/tools/index.ts @@ -6,13 +6,13 @@ import * as listContainerTags from "./list-container-tags" import * as listDocuments from "./list-documents" import * as listMemories from "./list-memories" import * as memoryGraph from "./memory-graph" +import * as prepareFileUpload from "./prepare-file-upload" import * as saveMemory from "./save-memory" import * as searchMemory from "./search-memory" import * as selectSpace from "./select-space" 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) { @@ -30,5 +30,5 @@ export function registerAllTools(deps: ToolDeps) { guidedSave.register(deps) saveMemory.register(deps) uploadFile.register(deps) - uploadFileSubmit.register(deps) + prepareFileUpload.register(deps) } diff --git a/apps/mcp/src/server/tools/memory-graph.ts b/apps/mcp/src/server/tools/memory-graph.ts index 5b0177b7..173c0ed3 100644 --- a/apps/mcp/src/server/tools/memory-graph.ts +++ b/apps/mcp/src/server/tools/memory-graph.ts @@ -1,9 +1,5 @@ import { z } from "zod" -import { - graphViewSchema, - type GraphResultMeta, - type ViewMessage, -} from "../../shared/types" +import { graphViewSchema, type ViewMessage } from "../../shared/types" import { appResultMeta, appToolMeta } from "../app-metadata" import { optionalContainerTagSchema } from "../container-tag" import { READ_ONLY_TOOL_ANNOTATIONS } from "./annotations" @@ -47,10 +43,6 @@ export function register(deps: ToolDeps) { truncated: result.documents.length < result.pagination.totalItems, rendered: true, } - const graphMeta: GraphResultMeta = { - graphData: { documents: result.documents }, - } - return { content: [ textContent( @@ -58,7 +50,7 @@ export function register(deps: ToolDeps) { ), ], structuredContent: sc, - _meta: { ...appResultMeta(viewId), ...graphMeta }, + _meta: appResultMeta(viewId), } } catch (error) { return deps.errorResult(error) diff --git a/apps/mcp/src/server/tools/prepare-file-upload.ts b/apps/mcp/src/server/tools/prepare-file-upload.ts new file mode 100644 index 00000000..835e0cd3 --- /dev/null +++ b/apps/mcp/src/server/tools/prepare-file-upload.ts @@ -0,0 +1,30 @@ +import { z } from "zod" +import { uploadPreparationSchema } from "../../shared/types" +import { appToolMeta } from "../app-metadata" +import { ADDITIVE_MEMORY_TOOL_ANNOTATIONS } from "./annotations" +import { textContent, type ToolDeps } from "./types" + +export function register(deps: ToolDeps) { + deps.server.registerTool( + "prepare-file-upload", + { + description: "Prepare a direct file upload", + inputSchema: z.object({}), + outputSchema: uploadPreparationSchema, + annotations: ADDITIVE_MEMORY_TOOL_ANNOTATIONS, + _meta: appToolMeta(["app"]), + }, + async () => { + try { + const preparation = await deps.createUploadSession() + + return { + content: [textContent("Upload session prepared")], + structuredContent: preparation, + } + } catch (error) { + return deps.errorResult(error) + } + }, + ) +} diff --git a/apps/mcp/src/server/tools/types.ts b/apps/mcp/src/server/tools/types.ts index 0d07b1f5..badbdd74 100644 --- a/apps/mcp/src/server/tools/types.ts +++ b/apps/mcp/src/server/tools/types.ts @@ -8,6 +8,12 @@ import type { SessionInfo } from "../../shared/types" import type { SupermemoryClient } from "../client" import type { ActorContext } from "../types" +export interface PreparedUpload { + uploadUrl: string + uploadToken: string + expiresAt: number +} + // 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 { @@ -18,6 +24,7 @@ export interface ToolDeps { resolveContainerTag: (explicit?: string) => Promise getActiveContainerTag: () => Promise setActiveContainerTag: (containerTag: string) => Promise + createUploadSession: () => Promise getClientInfo: ( context: ServerContext, ) => { name: string; version?: string } | null diff --git a/apps/mcp/src/server/tools/upload-file-submit.ts b/apps/mcp/src/server/tools/upload-file-submit.ts deleted file mode 100644 index 3c534427..00000000 --- a/apps/mcp/src/server/tools/upload-file-submit.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { z } from "zod" -import { uploadSuccessViewSchema, type ViewMessage } from "../../shared/types" -import { appResultMeta, appToolMeta } from "../app-metadata" -import { containerTagSchema } from "../container-tag" -import { ADDITIVE_MEMORY_TOOL_ANNOTATIONS } from "./annotations" -import { textContent, type ToolDeps } from "./types" - -export function register(deps: ToolDeps) { - deps.server.registerTool( - "upload-file-submit", - { - description: "Submit a file upload", - inputSchema: z.object({ - fileData: z.string().describe("Base64-encoded file content"), - fileName: z.string(), - mimeType: z.string(), - containerTag: containerTagSchema, - viewId: z.string().uuid().optional(), - }), - outputSchema: uploadSuccessViewSchema, - annotations: ADDITIVE_MEMORY_TOOL_ANNOTATIONS, - _meta: appToolMeta(["app"]), - }, - async (args) => { - try { - const viewId = args.viewId ?? crypto.randomUUID() - 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, - args.fileName, - args.mimeType, - args.containerTag, - ) - - const sc: ViewMessage = { - view: "upload-success", - viewId, - id: result.id, - fileName: args.fileName, - containerTag: args.containerTag, - } - - return { - content: [ - textContent(`File uploaded: ${args.fileName} → ${result.id}`), - ], - structuredContent: sc, - _meta: appResultMeta(viewId), - } - } catch (error) { - return deps.errorResult(error) - } - }, - ) -} diff --git a/apps/mcp/src/server/types.ts b/apps/mcp/src/server/types.ts index c4bc13d5..fe48f3b2 100644 --- a/apps/mcp/src/server/types.ts +++ b/apps/mcp/src/server/types.ts @@ -11,6 +11,7 @@ export interface ServerEnv { SPACE_STATE: DurableObjectNamespace API_URL?: string MCP_RESOURCE?: string + MCP_PUBLIC_ORIGIN?: string ALLOWED_MCP_ORIGIN_HOSTNAMES?: string POSTHOG_API_KEY?: string POSTHOG_HOST?: string diff --git a/apps/mcp/src/server/widget-resource-metadata.ts b/apps/mcp/src/server/widget-resource-metadata.ts index fdc74f0f..dbf29529 100644 --- a/apps/mcp/src/server/widget-resource-metadata.ts +++ b/apps/mcp/src/server/widget-resource-metadata.ts @@ -3,22 +3,30 @@ export const WIDGET_DESCRIPTION = const WIDGET_DOMAIN = "https://mcp.supermemory.ai" -export const WIDGET_RESOURCE_UI_META = { - prefersBorder: true, - csp: { - resourceDomains: [ - "https://fonts.googleapis.com", - "https://fonts.gstatic.com", - ], - connectDomains: [ - "https://fonts.googleapis.com", - "https://fonts.gstatic.com", - ], - }, +function widgetResourceUiMeta(connectOrigin = WIDGET_DOMAIN) { + return { + prefersBorder: true, + csp: { + resourceDomains: [ + "https://fonts.googleapis.com", + "https://fonts.gstatic.com", + ], + connectDomains: [ + "https://fonts.googleapis.com", + "https://fonts.gstatic.com", + ...new Set([WIDGET_DOMAIN, connectOrigin]), + ], + }, + } } -export const WIDGET_RESOURCE_META = { - ui: WIDGET_RESOURCE_UI_META, - "openai/widgetDescription": WIDGET_DESCRIPTION, - "openai/widgetDomain": WIDGET_DOMAIN, +export function widgetResourceMeta(connectOrigin = WIDGET_DOMAIN) { + return { + ui: widgetResourceUiMeta(connectOrigin), + "openai/widgetDescription": WIDGET_DESCRIPTION, + "openai/widgetDomain": WIDGET_DOMAIN, + } } + +export const WIDGET_RESOURCE_UI_META = widgetResourceUiMeta() +export const WIDGET_RESOURCE_META = widgetResourceMeta() diff --git a/apps/mcp/src/shared/types.ts b/apps/mcp/src/shared/types.ts index 83e61845..b8c4fe35 100644 --- a/apps/mcp/src/shared/types.ts +++ b/apps/mcp/src/shared/types.ts @@ -165,12 +165,23 @@ export const uploadSuccessViewSchema = z.object({ containerTag: z.string(), }) +export const uploadPreparationSchema = z.object({ + uploadUrl: z.string().url(), + uploadToken: z.string().min(1), + expiresAt: z.number().int().positive(), +}) + +export const uploadResponseSchema = z.object({ + id: z.string(), + status: z.string(), +}) + export const graphViewSchema = z.object({ view: z.literal("graph"), viewId: viewIdSchema, containerTag: z.string().optional(), - documents: z.array(documentWithMemoriesSchema).optional(), - totalCount: z.number().int().nonnegative().optional(), + documents: z.array(documentWithMemoriesSchema), + totalCount: z.number().int().nonnegative(), documentCount: z.number().int().nonnegative(), memoryCount: z.number().int().nonnegative(), totalDocumentCount: z.number().int().nonnegative(), @@ -190,12 +201,4 @@ export const viewMessageSchema = z.discriminatedUnion("view", [ export type ViewMessage = z.infer -export const graphResultMetaSchema = z.looseObject({ - graphData: z.object({ - documents: z.array(documentWithMemoriesSchema), - }), -}) - -export type GraphResultMeta = z.infer - export type ViewName = ViewMessage["view"] diff --git a/apps/mcp/src/widget/App.tsx b/apps/mcp/src/widget/App.tsx index bd06ee82..750aec70 100644 --- a/apps/mcp/src/widget/App.tsx +++ b/apps/mcp/src/widget/App.tsx @@ -1,7 +1,6 @@ -import { type ReactNode, useEffect } from "react" +import type { ReactNode } 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" @@ -14,17 +13,8 @@ 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 ( @@ -130,13 +120,7 @@ function renderView( /> ) case "graph": - return ( - - ) + return case "confirmation": return case "save-success": diff --git a/apps/mcp/src/widget/ErrorBoundary.tsx b/apps/mcp/src/widget/ErrorBoundary.tsx index 6dbe70b7..6e046e9c 100644 --- a/apps/mcp/src/widget/ErrorBoundary.tsx +++ b/apps/mcp/src/widget/ErrorBoundary.tsx @@ -1,11 +1,5 @@ -import { - Component, - type ContextType, - type ErrorInfo, - type ReactNode, -} from "react" +import { Component, type ReactNode } from "react" import { Button, Stack } from "./design/ui" -import { McpAppContext } from "./McpAppProvider" interface Props { children: ReactNode @@ -17,32 +11,11 @@ interface State { export class ErrorBoundary extends Component { state: State = { error: null } - static contextType = McpAppContext - declare context: ContextType static getDerivedStateFromError(error: Error): State { return { error } } - componentDidCatch(error: Error, info: ErrorInfo) { - try { - const report = this.context?.app?.sendLog({ - level: "error", - logger: "ErrorBoundary", - data: `${error.name}: ${error.message}\n${info.componentStack ?? ""}`, - }) - if (report) { - void report.catch(() => { - console.error("[ErrorBoundary]", error, info) - }) - } else { - console.error("[ErrorBoundary]", error, info) - } - } catch { - console.error("[ErrorBoundary]", error, info) - } - } - private handleReload = () => this.setState({ error: null }) render() { diff --git a/apps/mcp/src/widget/McpAppProvider.tsx b/apps/mcp/src/widget/McpAppProvider.tsx index 8fa33a93..d7254b23 100644 --- a/apps/mcp/src/widget/McpAppProvider.tsx +++ b/apps/mcp/src/widget/McpAppProvider.tsx @@ -30,20 +30,6 @@ export interface McpAppContextValue { export const McpAppContext = createContext(null) -function safeLog( - app: McpApp, - level: "debug" | "info" | "warning" | "error", - message: string, -) { - try { - void app.sendLog({ level, data: message }).catch(() => { - // Host logging is optional. - }) - } catch { - // The transport may not be ready yet. - } -} - function initialViewState(): ViewState { const checkpoint = loadViewCheckpoint() return checkpoint @@ -61,29 +47,21 @@ export function McpAppProvider({ children }: { children: ReactNode }) { strict: true, onAppCreated: (createdApp) => { createdApp.ontoolinput = () => { - safeLog(createdApp, "info", "[host] ontoolinput") setState({ kind: "loading" }) } createdApp.ontoolinputpartial = () => setState({ kind: "loading" }) createdApp.ontoolcancelled = () => { - safeLog(createdApp, "info", "[host] ontoolcancelled") setState({ kind: "loading" }) } createdApp.ontoolresult = (result) => { const structuredContent = result.structuredContent const parsedMessage = viewMessageSchema.safeParse(structuredContent) if (!parsedMessage.success) { - safeLog( - createdApp, - "warning", - "[host] ontoolresult: invalid structuredContent", - ) setState({ kind: "raw", structuredContent }) return } const message = parsedMessage.data - safeLog(createdApp, "info", `[host] ontoolresult: view=${message.view}`) const checkpoint = loadViewCheckpoint(message.viewId) if (checkpoint) { setState({ kind: "view", message: checkpoint }) @@ -96,7 +74,6 @@ export function McpAppProvider({ children }: { children: ReactNode }) { setHostContext(createdApp.getHostContext() ?? next) } createdApp.onerror = (nextError: unknown) => { - safeLog(createdApp, "error", `[host] onerror: ${String(nextError)}`) setState({ kind: "error", message: String(nextError) }) } }, diff --git a/apps/mcp/src/widget/hooks/useApp.ts b/apps/mcp/src/widget/hooks/useApp.ts index 2d8b6de0..876d8dc7 100644 --- a/apps/mcp/src/widget/hooks/useApp.ts +++ b/apps/mcp/src/widget/hooks/useApp.ts @@ -101,13 +101,6 @@ export function useApp() { return performModelHandoff(app, request) }, - /** Send a structured log line to the host. */ - log(level: "debug" | "info" | "warning" | "error", message: string) { - return app - ? app.sendLog({ level, data: message }) - : Promise.resolve(undefined) - }, - /** Request the host to switch display mode. */ requestDisplayMode(mode: "inline" | "fullscreen" | "pip") { return app diff --git a/apps/mcp/src/widget/hooks/useLog.ts b/apps/mcp/src/widget/hooks/useLog.ts deleted file mode 100644 index 508e3d1e..00000000 --- a/apps/mcp/src/widget/hooks/useLog.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { useCallback } from "react" -import { useApp } from "./useApp" - -/** - * Convenience wrapper around `app.sendLog` that swallows transport errors — - * widgets shouldn't crash because the host couldn't accept a log message. - */ -export function useLog() { - const { log } = useApp() - return useCallback( - (level: "debug" | "info" | "warning" | "error", message: string) => { - log(level, message).catch(() => { - /* host may not support logging — ignore */ - }) - }, - [log], - ) -} diff --git a/apps/mcp/src/widget/lib/modelHandoff.test.ts b/apps/mcp/src/widget/lib/modelHandoff.test.ts deleted file mode 100644 index 2fa289f7..00000000 --- a/apps/mcp/src/widget/lib/modelHandoff.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { describe, expect, it, vi } from "vitest" -import { handoffToModel } from "./modelHandoff" - -function createApp(overrides?: { - updateModelContext?: () => Promise - sendMessage?: () => Promise<{ isError?: boolean }> -}) { - return { - updateModelContext: - overrides?.updateModelContext ?? vi.fn(async () => ({})), - sendMessage: overrides?.sendMessage ?? vi.fn(async () => ({})), - } -} - -const request = { - context: "Detailed state", - message: "Continue from the widget action", - structuredContent: { action: "saved" }, -} - -describe("handoffToModel", () => { - it("updates context before sending the portable conversation message", async () => { - const order: string[] = [] - const app = createApp({ - updateModelContext: vi.fn(async () => { - order.push("context") - }), - sendMessage: vi.fn(async () => { - order.push("message") - return {} - }), - }) - - const result = await handoffToModel(app, request, undefined) - - expect(order).toEqual(["context", "message"]) - expect(result).toEqual({ - ok: true, - contextUpdate: { ok: true }, - conversationMessage: { ok: true }, - }) - expect(app.updateModelContext).toHaveBeenCalledWith({ - content: [{ type: "text", text: request.context }], - structuredContent: request.structuredContent, - }) - }) - - it("prefers ChatGPT's follow-up helper when available", async () => { - const app = createApp() - const sendFollowUpMessage = vi.fn(async () => undefined) - - const result = await handoffToModel(app, request, { - sendFollowUpMessage, - }) - - expect(result.conversationMessage).toEqual({ ok: true }) - expect(sendFollowUpMessage).toHaveBeenCalledWith({ - prompt: request.message, - scrollToBottom: true, - }) - expect(app.sendMessage).not.toHaveBeenCalled() - }) - - it("falls back to the portable message when ChatGPT's helper fails", async () => { - const app = createApp() - - const result = await handoffToModel(app, request, { - sendFollowUpMessage: vi.fn(async () => { - throw new Error("unavailable") - }), - }) - - expect(result.conversationMessage).toEqual({ ok: true }) - expect(app.sendMessage).toHaveBeenCalledOnce() - }) - - it("still sends the conversation message when context publication fails", async () => { - const app = createApp({ - updateModelContext: vi.fn(async () => { - throw new Error("unsupported") - }), - }) - - const result = await handoffToModel(app, request, undefined) - - expect(result.ok).toBe(true) - expect(result.contextUpdate).toMatchObject({ - ok: false, - error: "Error: unsupported", - }) - expect(app.sendMessage).toHaveBeenCalledOnce() - }) - - it("reports a rejected conversation message as the failed handoff", async () => { - const app = createApp({ - sendMessage: vi.fn(async () => ({ isError: true })), - }) - - const result = await handoffToModel(app, request, undefined) - - expect(result.ok).toBe(false) - expect(result.conversationMessage).toEqual({ - ok: false, - error: "Host rejected the MCP Apps message", - }) - }) -}) diff --git a/apps/mcp/src/widget/lib/readFileAsBase64.ts b/apps/mcp/src/widget/lib/readFileAsBase64.ts deleted file mode 100644 index 3561e57c..00000000 --- a/apps/mcp/src/widget/lib/readFileAsBase64.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Reads a File as a base64-encoded string (without the `data:...;base64,` prefix). - */ -export function readFileAsBase64(file: File): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader() - reader.onload = () => { - if (typeof reader.result !== "string") { - reject(new Error("Unable to read file as base64")) - return - } - const result = reader.result - const comma = result.indexOf(",") - resolve(comma >= 0 ? result.slice(comma + 1) : result) - } - reader.onerror = () => reject(reader.error ?? new Error("read failed")) - reader.readAsDataURL(file) - }) -} diff --git a/apps/mcp/src/widget/lib/viewCheckpoint.test.ts b/apps/mcp/src/widget/lib/viewCheckpoint.test.ts deleted file mode 100644 index 5109601f..00000000 --- a/apps/mcp/src/widget/lib/viewCheckpoint.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest" -import type { ViewMessage } from "../../shared/types" -import { loadViewCheckpoint, saveViewCheckpoint } from "./viewCheckpoint" - -function createStorage() { - const values = new Map() - return { - getItem: vi.fn((key: string) => values.get(key) ?? null), - setItem: vi.fn((key: string, value: string) => { - values.set(key, value) - }), - removeItem: vi.fn((key: string) => { - values.delete(key) - }), - clear: vi.fn(() => values.clear()), - key: vi.fn(() => null), - get length() { - return values.size - }, - } -} - -afterEach(() => { - vi.unstubAllGlobals() -}) - -describe("view checkpoints", () => { - it("restores a completed view from localStorage by stable view id", () => { - const storage = createStorage() - vi.stubGlobal("localStorage", storage) - const view: ViewMessage = { - view: "save-success", - viewId: "80fa74b9-8347-45f7-a2b5-d7a3d5d67616", - id: "memory-123", - containerTag: "model_test", - } - - saveViewCheckpoint(view) - - expect(loadViewCheckpoint(view.viewId)).toEqual(view) - }) - - it("does not persist non-terminal form views", () => { - const storage = createStorage() - vi.stubGlobal("localStorage", storage) - const view: ViewMessage = { - view: "save", - viewId: "80fa74b9-8347-45f7-a2b5-d7a3d5d67616", - writableTags: ["model_test"], - } - - saveViewCheckpoint(view) - - expect(storage.setItem).not.toHaveBeenCalled() - expect(loadViewCheckpoint(view.viewId)).toBeNull() - }) - - it("mirrors compact state into ChatGPT widget state", () => { - const storage = createStorage() - const setWidgetState = vi.fn() - vi.stubGlobal("localStorage", storage) - vi.stubGlobal("window", { - openai: { - widgetState: { - privateContent: { existing: true }, - }, - setWidgetState, - }, - }) - const view: ViewMessage = { - view: "confirmation", - viewId: "80fa74b9-8347-45f7-a2b5-d7a3d5d67616", - containerTag: "model_test", - } - - saveViewCheckpoint(view) - - expect(setWidgetState).toHaveBeenCalledWith({ - modelContent: 'Supermemory active space is now "model_test".', - privateContent: { - existing: true, - supermemoryView: view, - }, - }) - }) - - it("can restore from ChatGPT widget state before a tool result is replayed", () => { - const view: ViewMessage = { - view: "upload-success", - viewId: "80fa74b9-8347-45f7-a2b5-d7a3d5d67616", - id: "document-123", - fileName: "notes.txt", - containerTag: "model_test", - } - vi.stubGlobal("window", { - openai: { - widgetState: { - privateContent: { supermemoryView: view }, - }, - }, - }) - - expect(loadViewCheckpoint()).toEqual(view) - }) -}) diff --git a/apps/mcp/src/widget/studio/Studio.tsx b/apps/mcp/src/widget/studio/Studio.tsx index f44eec33..54d83fb9 100644 --- a/apps/mcp/src/widget/studio/Studio.tsx +++ b/apps/mcp/src/widget/studio/Studio.tsx @@ -425,8 +425,8 @@ export function Studio() { diff --git a/apps/mcp/src/widget/views/Graph.tsx b/apps/mcp/src/widget/views/Graph.tsx index 5b61c5f3..1c9631be 100644 --- a/apps/mcp/src/widget/views/Graph.tsx +++ b/apps/mcp/src/widget/views/Graph.tsx @@ -6,18 +6,14 @@ import { MemoryGraph, } from "@supermemory/memory-graph" import { useCallback, useEffect, useMemo, useRef, useState } from "react" -import { - documentsApiResponseSchema, - type DocumentMemoryEntry, - type DocumentWithMemories, +import type { + DocumentMemoryEntry, + DocumentWithMemories, } from "../../shared/types" import { cn } from "../design/lib/cn" import { useApp } from "../hooks/useApp" import { useHostContext } from "../hooks/useHostContext" -import { useLog } from "../hooks/useLog" import { ArrowsIn, ArrowsOut } from "../lib/icons" -import { ErrorView } from "./Error" -import { Loading } from "./Loading" // GraphThemeColors key → the --graph-* CSS variable it resolves from. Same // mapping as the package's internal useGraphTheme, but we drive it ourselves @@ -84,9 +80,8 @@ function useGraphColors(theme: string): GraphThemeColors { } interface Props { - containerTag?: string - initialDocuments?: DocumentWithMemories[] - initialTotalCount: number + documents: DocumentWithMemories[] + totalCount: number } // Map the widget's API shape (DocumentWithMemories) into the package's @@ -127,48 +122,12 @@ function toGraphDocument(doc: DocumentWithMemories): GraphApiDocument { } } -export function Graph({ - containerTag, - initialDocuments, - initialTotalCount, -}: Props) { - const { callTool, isConnected, requestDisplayMode } = useApp() +export function Graph({ documents, totalCount }: Props) { + const { requestDisplayMode } = useApp() const ctx = useHostContext() - const log = useLog() - const [documents, setDocuments] = useState(initialDocuments) - const [totalCount, setTotalCount] = useState(initialTotalCount) - const [loadError, setLoadError] = useState(null) - - useEffect(() => { - if (!isConnected) return - let active = true - void callTool( - "fetch-graph-data", - { - ...(containerTag ? { containerTag } : {}), - page: 1, - limit: 200, - }, - documentsApiResponseSchema, - ).then((result) => { - if (!active) return - if (!result.ok || !result.data) { - if (!initialDocuments) { - setLoadError(result.error ?? "Failed to load graph data") - } - return - } - setDocuments(result.data.documents) - setTotalCount(result.data.pagination.totalItems) - setLoadError(null) - }) - return () => { - active = false - } - }, [callTool, containerTag, initialDocuments, isConnected]) const graphDocuments = useMemo( - () => (documents ?? []).map(toGraphDocument), + () => documents.map(toGraphDocument), [documents], ) @@ -206,15 +165,11 @@ export function Graph({ const toggleFullscreen = useCallback(async () => { const next = mode === "fullscreen" ? "inline" : "fullscreen" - log("info", `[graph] fullscreen toggle: ${mode} → ${next}`) setMode(next) // optimistic — button + container update immediately try { - const result = await requestDisplayMode(next) - log("info", `[graph] requestDisplayMode result: ${result?.mode ?? "?"}`) - } catch (err) { - log("error", `[graph] requestDisplayMode failed: ${err}`) - } - }, [mode, requestDisplayMode, log]) + await requestDisplayMode(next) + } catch {} + }, [mode, requestDisplayMode]) // ESC exits fullscreen — matches Excalidraw and host-page UX. useEffect(() => { @@ -229,9 +184,6 @@ export function Graph({ return () => document.removeEventListener("keydown", handler) }, [mode, toggleFullscreen]) - if (loadError) return - if (!documents) return - return (
(null) const [query, setQuery] = useState("") @@ -48,7 +46,6 @@ export function Picker({ }, [containerTags, query]) const handleSelect = async (containerTag: string) => { - log("info", `[picker] select: ${containerTag}`) setPending(containerTag) const result = await callTool( "set-active-tag", @@ -60,12 +57,11 @@ export function Picker({ ) setPending(null) if (!result.ok || !result.data) { - log("error", `[picker] set-active-tag failed: ${result.error}`) onError(result.error ?? "Failed to set active space") return } onAdvance(result.data) - const handoff = await handoffToModel({ + await handoffToModel({ context: `Supermemory space selection changed. Active space: "${containerTag}". Use it for future Supermemory actions until another space is selected.`, message: `I selected "${containerTag}" as my active Supermemory space. Use this space for future Supermemory actions until I select another one.`, structuredContent: { @@ -75,18 +71,6 @@ export function Picker({ }, }, }) - if (!handoff.contextUpdate.ok) { - log( - "warning", - `[picker] model context update failed: ${handoff.contextUpdate.error}`, - ) - } - if (!handoff.conversationMessage.ok) { - log( - "warning", - `[picker] agent handoff failed: ${handoff.conversationMessage.error}`, - ) - } } const count = containerTags.length diff --git a/apps/mcp/src/widget/views/Save.tsx b/apps/mcp/src/widget/views/Save.tsx index 0400291b..355f0036 100644 --- a/apps/mcp/src/widget/views/Save.tsx +++ b/apps/mcp/src/widget/views/Save.tsx @@ -10,7 +10,6 @@ import { SpaceSelect, } from "../design/ui" import { useApp } from "../hooks/useApp" -import { useLog } from "../hooks/useLog" import { formatTagLabel } from "../lib/formatTag" interface Props { @@ -31,7 +30,6 @@ export function Save({ viewId, }: Props) { const { callTool, handoffToModel } = useApp() - const log = useLog() const [content, setContent] = useState(prefill ?? "") const [selectedTag, setSelectedTag] = useState( activeTag ?? writableTags[0] ?? null, @@ -59,7 +57,6 @@ export function Save({ const handleSave = async () => { if (!canSave || !selectedTag) return - log("info", `[save] submit (${trimmed.length} chars → ${selectedTag})`) setSaving(true) const result = await callTool( "save-memory", @@ -72,14 +69,13 @@ export function Save({ ) setSaving(false) if (!result.ok || !result.data) { - log("error", `[save] failed: ${result.error}`) onError(result.error ?? "Failed to save memory") return } const memoryId = result.data.view === "save-success" ? result.data.id : undefined onAdvance(result.data) - const handoff = await handoffToModel({ + await handoffToModel({ context: `Supermemory widget action completed. A memory was saved to space "${selectedTag}"${memoryId ? ` with memory ID "${memoryId}"` : ""}. Saved content:\n\n${trimmed}\n\nIt is already saved; do not save it again.`, message: `I used the Supermemory widget to save a memory to space "${selectedTag}"${memoryId ? ` (memory ID: ${memoryId})` : ""}. The memory is already saved; do not save it again.`, structuredContent: { @@ -91,18 +87,6 @@ export function Save({ }, }, }) - if (!handoff.contextUpdate.ok) { - log( - "warning", - `[save] model context update failed: ${handoff.contextUpdate.error}`, - ) - } - if (!handoff.conversationMessage.ok) { - log( - "warning", - `[save] agent handoff failed: ${handoff.conversationMessage.error}`, - ) - } } return ( diff --git a/apps/mcp/src/widget/views/Upload.tsx b/apps/mcp/src/widget/views/Upload.tsx index e69e8f7f..e388aa51 100644 --- a/apps/mcp/src/widget/views/Upload.tsx +++ b/apps/mcp/src/widget/views/Upload.tsx @@ -1,5 +1,9 @@ import { useMemo, useState } from "react" -import { viewMessageSchema, type ViewMessage } from "../../shared/types" +import { + uploadPreparationSchema, + uploadResponseSchema, + type ViewMessage, +} from "../../shared/types" import { ActionGroup, Button, @@ -10,10 +14,8 @@ import { SpaceSelect, } from "../design/ui" import { useApp } from "../hooks/useApp" -import { useLog } from "../hooks/useLog" import { formatTagLabel } from "../lib/formatTag" import { FileText, X } from "../lib/icons" -import { readFileAsBase64 } from "../lib/readFileAsBase64" interface Props { activeTag?: string | null @@ -40,7 +42,6 @@ export function Upload({ viewId, }: Props) { const { callTool, handoffToModel } = useApp() - const log = useLog() const [file, setFile] = useState(null) const [selectedTag, setSelectedTag] = useState( activeTag ?? writableTags[0] ?? null, @@ -61,55 +62,67 @@ export function Upload({ const handleUpload = async () => { if (!file || !selectedTag) return - log("info", `[upload] submit ${file.name} (${file.size}B → ${selectedTag})`) setUploading(true) try { - const fileData = await readFileAsBase64(file) - const result = await callTool( - "upload-file-submit", - { - fileData, - fileName: file.name, - mimeType: file.type, - containerTag: selectedTag, - viewId, - }, - viewMessageSchema, + const preparation = await callTool( + "prepare-file-upload", + {}, + uploadPreparationSchema, ) - if (!result.ok || !result.data) { - log("error", `[upload] failed: ${result.error}`) - onError(result.error ?? "Upload failed") + if (!preparation.ok || !preparation.data) { + onError(preparation.error ?? "Unable to prepare upload") return } - const documentId = - result.data.view === "upload-success" ? result.data.id : undefined - onAdvance(result.data) - const handoff = await handoffToModel({ - context: `Supermemory widget action completed. "${file.name}" was uploaded to space "${selectedTag}"${documentId ? ` with document ID "${documentId}"` : ""}. It is already uploaded; do not upload it again.`, - message: `I used the Supermemory widget to upload "${file.name}" to space "${selectedTag}"${documentId ? ` (document ID: ${documentId})` : ""}. The file is already uploaded; do not upload it again.`, + + const formData = new FormData() + formData.append("file", file, file.name) + formData.append("containerTag", selectedTag) + formData.append( + "metadata", + JSON.stringify({ sm_source: "supermemory-mcp" }), + ) + + const response = await fetch(preparation.data.uploadUrl, { + method: "POST", + headers: { + Authorization: `Bearer ${preparation.data.uploadToken}`, + }, + body: formData, + }) + if (!response.ok) { + const message = + (await response.text()) || `Upload failed (${response.status})` + onError(message) + return + } + + const uploaded = uploadResponseSchema.safeParse(await response.json()) + if (!uploaded.success) { + onError("Upload returned an invalid response") + return + } + + const result: ViewMessage = { + view: "upload-success", + viewId, + id: uploaded.data.id, + fileName: file.name, + containerTag: selectedTag, + } + onAdvance(result) + await handoffToModel({ + context: `Supermemory widget action completed. "${file.name}" was uploaded to space "${selectedTag}" with document ID "${uploaded.data.id}". It is already uploaded; do not upload it again.`, + message: `I used the Supermemory widget to upload "${file.name}" to space "${selectedTag}" (document ID: ${uploaded.data.id}). The file is already uploaded; do not upload it again.`, structuredContent: { supermemory: { action: "file-uploaded", activeSpace: selectedTag, - documentId, + documentId: uploaded.data.id, fileName: file.name, }, }, }) - if (!handoff.contextUpdate.ok) { - log( - "warning", - `[upload] model context update failed: ${handoff.contextUpdate.error}`, - ) - } - if (!handoff.conversationMessage.ok) { - log( - "warning", - `[upload] agent handoff failed: ${handoff.conversationMessage.error}`, - ) - } } catch (err) { - log("error", `[upload] threw: ${err}`) onError(String(err)) } finally { setUploading(false)