fix mcp graph and file uploads (#1397)

Fixes cross-host graph rendering and moves widget uploads off the JSON/base64 tool transport.

- render graph data from the launcher result without a second tool call
- stream multipart uploads through one-time, short-lived upload sessions
- remove temporary widget diagnostics and redundant unit tests

Tested with Biome, TypeScript, 17 unit tests, a Wrangler deployment dry-run, and live graph rendering in ChatGPT and Claude. Authenticated E2E setup is currently blocked by the saved OAuth refresh session returning `invalid_grant: session not found`.
This commit is contained in:
Prasanna721 2026-08-04 18:58:14 +00:00
parent 434c86e2c3
commit a99cf4f7e1
35 changed files with 351 additions and 1030 deletions

View file

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

View file

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

View file

@ -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 () => {

View file

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

View file

@ -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> = {},
): DocumentsListResponse {
return {
documents: [],
pagination: { currentPage: 1, limit: 10, totalItems: 0, totalPages: 1 },
...overrides,
}
}
function makeMemoryResponse(
overrides: Partial<MemoryEntriesResponse> = {},
): MemoryEntriesResponse {
return {
memoryEntries: [],
pagination: { currentPage: 1, limit: 10, totalItems: 0, totalPages: 1 },
...overrides,
}
}
function makeMemory(memory: string, extra: Record<string, unknown> = {}) {
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")
})
})

View file

@ -46,7 +46,7 @@ const TOOL_SURFACES: Record<string, McpToolSurface> = {
"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",
}

View file

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

View file

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

View file

@ -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 (

View file

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

View file

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

View file

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

View file

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

View file

@ -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<string> {
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<string | undefined> {
@ -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<void> {
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<UploadSession | undefined> {
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<void> {
await this.ctx.storage.delete(UPLOAD_SESSION_KEY)
}
}

View file

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

View file

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

View file

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

View file

@ -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<string>
getActiveContainerTag: () => Promise<string | undefined>
setActiveContainerTag: (containerTag: string) => Promise<void>
createUploadSession: () => Promise<PreparedUpload>
getClientInfo: (
context: ServerContext,
) => { name: string; version?: string } | null

View file

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

View file

@ -11,6 +11,7 @@ export interface ServerEnv {
SPACE_STATE: DurableObjectNamespace<SpaceState>
API_URL?: string
MCP_RESOURCE?: string
MCP_PUBLIC_ORIGIN?: string
ALLOWED_MCP_ORIGIN_HOSTNAMES?: string
POSTHOG_API_KEY?: string
POSTHOG_HOST?: string

View file

@ -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()

View file

@ -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<typeof viewMessageSchema>
export const graphResultMetaSchema = z.looseObject({
graphData: z.object({
documents: z.array(documentWithMemoriesSchema),
}),
})
export type GraphResultMeta = z.infer<typeof graphResultMetaSchema>
export type ViewName = ViewMessage["view"]

View file

@ -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 (
<WidgetShell>
@ -130,13 +120,7 @@ function renderView(
/>
)
case "graph":
return (
<Graph
containerTag={msg.containerTag}
initialDocuments={msg.documents}
initialTotalCount={msg.totalCount ?? msg.totalDocumentCount}
/>
)
return <Graph documents={msg.documents} totalCount={msg.totalCount} />
case "confirmation":
return <Confirmation containerTag={msg.containerTag} />
case "save-success":

View file

@ -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<Props, State> {
state: State = { error: null }
static contextType = McpAppContext
declare context: ContextType<typeof McpAppContext>
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() {

View file

@ -30,20 +30,6 @@ export interface McpAppContextValue {
export const McpAppContext = createContext<McpAppContextValue | null>(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) })
}
},

View file

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

View file

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

View file

@ -1,107 +0,0 @@
import { describe, expect, it, vi } from "vitest"
import { handoffToModel } from "./modelHandoff"
function createApp(overrides?: {
updateModelContext?: () => Promise<unknown>
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",
})
})
})

View file

@ -1,19 +0,0 @@
/**
* Reads a File as a base64-encoded string (without the `data:...;base64,` prefix).
*/
export function readFileAsBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
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)
})
}

View file

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

View file

@ -425,8 +425,8 @@ export function Studio() {
<Frame label="Graph (@supermemory/memory-graph)" width={frameWidth}>
<WidgetShell immersive>
<Graph
initialDocuments={mockDocuments}
initialTotalCount={mockDocuments.length}
documents={mockDocuments}
totalCount={mockDocuments.length}
/>
</WidgetShell>
</Frame>

View file

@ -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<string | null>(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 <ErrorView message={loadError} />
if (!documents) return <Loading />
return (
<div className="relative">
<div

View file

@ -8,7 +8,6 @@ import {
import { SpaceCard } from "../components/SpaceCard"
import { Input, PageHeader } from "../design/ui"
import { useApp } from "../hooks/useApp"
import { useLog } from "../hooks/useLog"
import { formatTagLabel } from "../lib/formatTag"
import { Package, Search } from "../lib/icons"
@ -33,7 +32,6 @@ export function Picker({
viewId,
}: Props) {
const { callTool, handoffToModel } = useApp()
const log = useLog()
const [pending, setPending] = useState<string | null>(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

View file

@ -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<string | null>(
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 (

View file

@ -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<File | null>(null)
const [selectedTag, setSelectedTag] = useState<string | null>(
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)