diff --git a/apps/mcp/README.md b/apps/mcp/README.md index 1cf39bc1..1bd4ab4c 100644 --- a/apps/mcp/README.md +++ b/apps/mcp/README.md @@ -74,6 +74,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 | | `prepare-file-upload` | Prepare a secure direct file upload | +| `upload-file-submit` | Compatibility upload action for older published app catalogs | | `fetch-graph-data` | Fetch graph documents for the app | ## Resources And Prompt @@ -89,6 +90,10 @@ The App resource and tool metadata include both current nested `ui` metadata and the legacy flat resource URI key while MCP Apps completes its SDK v2 migration. The Worker runtime does not import the SDK v1 Apps server helpers. +The widget SHA is a release cache-buster. Historical widget URIs resolve to the +latest compatible bundle, so app-only tool names and schemas referenced by +published catalogs must remain available until those catalogs are retired. + ## Development Install from the repository root: @@ -136,10 +141,9 @@ discovery and rejection tests still run. ## Storage And Rollout -`SpaceState` stores only the active space's container tag. It never stores bearer -tokens, MCP client identity, or protocol messages. +`SpaceState` stores the active space's container tag and short-lived, one-time +upload sessions. It does not store MCP protocol sessions, connections, messages, +or client identity. -The old `SupermemoryMCP` class and binding remain inert for one rollout. This -keeps the migration non-destructive and rollback-safe. A later deployment can -delete the old protocol class after production traffic and rollback windows -have been checked. +The old protocol `SupermemoryMCP` Durable Object class and binding were removed +with migration `v3`. MCP request handling remains stateless and per-request. diff --git a/apps/mcp/e2e/discovery.test.ts b/apps/mcp/e2e/discovery.test.ts index 3da201c2..27b61618 100644 --- a/apps/mcp/e2e/discovery.test.ts +++ b/apps/mcp/e2e/discovery.test.ts @@ -22,6 +22,7 @@ const EXPECTED_TOOLS = [ "select-space", "set-active-tag", "upload-file", + "upload-file-submit", "whoAmI", ] const describeWithAuth = describe.skipIf(!OAUTH_CREDENTIALS_AVAILABLE) diff --git a/apps/mcp/src/server/analytics.ts b/apps/mcp/src/server/analytics.ts index aac932d1..9e0e90ea 100644 --- a/apps/mcp/src/server/analytics.ts +++ b/apps/mcp/src/server/analytics.ts @@ -47,6 +47,7 @@ const TOOL_SURFACES: Record = { "set-active-tag": "app_action", "save-memory": "app_action", "prepare-file-upload": "app_action", + "upload-file-submit": "app_action", "fetch-graph-data": "app_internal", } diff --git a/apps/mcp/src/server/client/index.ts b/apps/mcp/src/server/client/index.ts index a2ccc8e7..4cb40756 100644 --- a/apps/mcp/src/server/client/index.ts +++ b/apps/mcp/src/server/client/index.ts @@ -8,6 +8,7 @@ import { containerTagSchema, documentsApiResponseSchema, memoriesListSchema, + uploadResponseSchema, type ContainerTag, type DocumentMemoryEntry, type DocumentsApiResponse, @@ -393,6 +394,45 @@ export class SupermemoryClient { } } + async uploadFile( + fileData: ArrayBuffer, + fileName: string, + mimeType: string, + containerTag?: string, + ): Promise<{ id: string; status: string }> { + try { + const formData = new FormData() + formData.append( + "file", + new Blob([fileData], { type: mimeType }), + fileName, + ) + if (containerTag) formData.append("containerTag", 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, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }) + + if (!response.ok) { + const message = extractApiErrorMessage(await response.text()) + throw Object.assign(new Error(message || "Upload failed"), { + status: response.status, + }) + } + + return uploadResponseSchema.parse(await response.json()) + } catch (error) { + this.handleError(error) + } + } + async listMemoryEntries( page = 1, limit = 50, diff --git a/apps/mcp/src/server/index.ts b/apps/mcp/src/server/index.ts index 70505a21..d9eee935 100644 --- a/apps/mcp/src/server/index.ts +++ b/apps/mcp/src/server/index.ts @@ -9,7 +9,6 @@ import { validateOAuthToken, type AuthUser, } from "./auth" -import { SupermemoryMCP } from "./legacy-protocol-state" import { createSupermemoryServer } from "./server" import type { ActorContext, ServerEnv } from "./types" import { SpaceState, uploadStateName } from "./space-state" @@ -243,6 +242,7 @@ async function handleMcpRequest( ), { route: "/mcp", + // Supports 2025-era requests without creating protocol session state. legacy: "stateless", corsOptions: false, allowedOriginHostnames: allowedOriginHostnames(c.env), @@ -309,7 +309,7 @@ app.all("/", (c) => handleMcpRequest(c, "/mcp")) app.all("/mcp", (c) => handleMcpRequest(c)) app.all("/mcp/", (c) => handleMcpRequest(c, "/mcp")) -export { SpaceState, SupermemoryMCP } +export { SpaceState } export type { ActorContext, ServerEnv } export default app diff --git a/apps/mcp/src/server/legacy-protocol-state.ts b/apps/mcp/src/server/legacy-protocol-state.ts deleted file mode 100644 index 8d4bf2f3..00000000 --- a/apps/mcp/src/server/legacy-protocol-state.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { DurableObject } from "cloudflare:workers" - -// Kept for one rollout so the old protocol Durable Object class remains -// deployable and rollback-safe. No request path uses this class anymore. -export class SupermemoryMCP extends DurableObject {} diff --git a/apps/mcp/src/server/resources/widget.ts b/apps/mcp/src/server/resources/widget.ts index e8fb3eb4..fbbe9906 100644 --- a/apps/mcp/src/server/resources/widget.ts +++ b/apps/mcp/src/server/resources/widget.ts @@ -36,6 +36,12 @@ export function registerWidgetResource( resourceConfig, async () => readWidgetResource(SUPERMEMORY_RESOURCE_URI), ) + // Hosts cache the widget under the resource URI they saw at review time and + // may re-fetch it long after a release changed the hash. Serving the current + // bundle for any historical URI is only sound while the bundle stays + // compatible with every published catalog (see "Storage And Rollout" in the + // README): app-only tools it calls, like upload-file-submit, must remain + // registered until those catalogs are retired. server.registerResource( "Supermemory MCP UI compatibility", new ResourceTemplate("ui://supermemory/app-{version}.html", { diff --git a/apps/mcp/src/server/server.ts b/apps/mcp/src/server/server.ts index 432bddd3..461cb186 100644 --- a/apps/mcp/src/server/server.ts +++ b/apps/mcp/src/server/server.ts @@ -57,7 +57,16 @@ export function createSupermemoryServer( name: "supermemory", version: "1.0.0", }, - { instructions: SERVER_INSTRUCTIONS }, + { + instructions: SERVER_INSTRUCTIONS, + // This per-request runtime has no cross-request notification bus. + // Modern list responses retain the SDK's zero-TTL cache hint. + capabilities: { + prompts: { listChanged: false }, + resources: { listChanged: false }, + tools: { listChanged: false }, + }, + }, ) const apiUrl = env.API_URL || DEFAULT_API_URL const spaceState = env.SPACE_STATE.getByName(spaceStateName(actor)) diff --git a/apps/mcp/src/server/tools/index.ts b/apps/mcp/src/server/tools/index.ts index 6effe28d..9af6ad8d 100644 --- a/apps/mcp/src/server/tools/index.ts +++ b/apps/mcp/src/server/tools/index.ts @@ -13,6 +13,7 @@ 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 +31,6 @@ 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/upload-file-submit.ts b/apps/mcp/src/server/tools/upload-file-submit.ts new file mode 100644 index 00000000..40c13550 --- /dev/null +++ b/apps/mcp/src/server/tools/upload-file-submit.ts @@ -0,0 +1,66 @@ +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" + +/** + * Compatibility action for published app catalogs that predate direct uploads. + * Keep this tool name and input schema stable until those catalogs are retired. + */ +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 result = await deps + .getClient(args.containerTag) + .uploadFile( + bytes.buffer, + args.fileName, + args.mimeType, + args.containerTag, + ) + + const structuredContent: ViewMessage = { + view: "upload-success", + viewId, + id: result.id, + fileName: args.fileName, + containerTag: args.containerTag, + } + + return { + content: [ + textContent(`File uploaded: ${args.fileName} → ${result.id}`), + ], + structuredContent, + _meta: appResultMeta(viewId), + } + } catch (error) { + return deps.errorResult(error) + } + }, + ) +} diff --git a/apps/mcp/src/widget/lib/readFileAsBase64.ts b/apps/mcp/src/widget/lib/readFileAsBase64.ts new file mode 100644 index 00000000..64c1cdb8 --- /dev/null +++ b/apps/mcp/src/widget/lib/readFileAsBase64.ts @@ -0,0 +1,15 @@ +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 comma = reader.result.indexOf(",") + resolve(comma >= 0 ? reader.result.slice(comma + 1) : reader.result) + } + reader.onerror = () => reject(reader.error ?? new Error("File read failed")) + reader.readAsDataURL(file) + }) +} diff --git a/apps/mcp/src/widget/views/Upload.tsx b/apps/mcp/src/widget/views/Upload.tsx index e388aa51..e864dcc2 100644 --- a/apps/mcp/src/widget/views/Upload.tsx +++ b/apps/mcp/src/widget/views/Upload.tsx @@ -2,6 +2,7 @@ import { useMemo, useState } from "react" import { uploadPreparationSchema, uploadResponseSchema, + uploadSuccessViewSchema, type ViewMessage, } from "../../shared/types" import { @@ -16,6 +17,7 @@ import { import { useApp } from "../hooks/useApp" import { formatTagLabel } from "../lib/formatTag" import { FileText, X } from "../lib/icons" +import { readFileAsBase64 } from "../lib/readFileAsBase64" interface Props { activeTag?: string | null @@ -69,55 +71,75 @@ export function Upload({ {}, uploadPreparationSchema, ) - if (!preparation.ok || !preparation.data) { - onError(preparation.error ?? "Unable to prepare upload") - return + let result: Extract + if (preparation.ok && preparation.data) { + 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 + } + + result = { + view: "upload-success", + viewId, + id: uploaded.data.id, + fileName: file.name, + containerTag: selectedTag, + } + } else { + // A published host catalog can expose the old action while loading the + // latest cache-busted widget from its historical resource URI. + const compatibilityUpload = await callTool( + "upload-file-submit", + { + fileData: await readFileAsBase64(file), + fileName: file.name, + mimeType: file.type, + containerTag: selectedTag, + viewId, + }, + uploadSuccessViewSchema, + ) + if (!compatibilityUpload.ok || !compatibilityUpload.data) { + onError( + compatibilityUpload.error ?? preparation.error ?? "Upload failed", + ) + return + } + result = compatibilityUpload.data } - 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.`, + context: `Supermemory widget action completed. "${file.name}" was uploaded to space "${selectedTag}" with document ID "${result.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: ${result.id}). The file is already uploaded; do not upload it again.`, structuredContent: { supermemory: { action: "file-uploaded", activeSpace: selectedTag, - documentId: uploaded.data.id, + documentId: result.id, fileName: file.name, }, }, diff --git a/apps/mcp/wrangler.jsonc b/apps/mcp/wrangler.jsonc index de21761b..cb97362c 100644 --- a/apps/mcp/wrangler.jsonc +++ b/apps/mcp/wrangler.jsonc @@ -24,10 +24,6 @@ "durable_objects": { "bindings": [ - { - "name": "MCP_SERVER", - "class_name": "SupermemoryMCP" - }, { "name": "SPACE_STATE", "class_name": "SpaceState" @@ -43,6 +39,10 @@ { "tag": "v2", "new_sqlite_classes": ["SpaceState"] + }, + { + "tag": "v3", + "deleted_classes": ["SupermemoryMCP"] } ],