fix stale upload widget contracts

This commit is contained in:
Prasanna A P 2026-08-26 12:30:14 -07:00
parent 3f7b9667c6
commit 2aa46d4b1b
13 changed files with 220 additions and 59 deletions

View file

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

View file

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

View file

@ -47,6 +47,7 @@ const TOOL_SURFACES: Record<string, McpToolSurface> = {
"set-active-tag": "app_action",
"save-memory": "app_action",
"prepare-file-upload": "app_action",
"upload-file-submit": "app_action",
"fetch-graph-data": "app_internal",
}

View file

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

View file

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

View file

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

View file

@ -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", {

View file

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

View file

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

View file

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

View file

@ -0,0 +1,15 @@
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 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)
})
}

View file

@ -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<ViewMessage, { view: "upload-success" }>
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,
},
},

View file

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