mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
fix stale upload widget contracts
This commit is contained in:
parent
3f7b9667c6
commit
2aa46d4b1b
13 changed files with 220 additions and 59 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
@ -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", {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
66
apps/mcp/src/server/tools/upload-file-submit.ts
Normal file
66
apps/mcp/src/server/tools/upload-file-submit.ts
Normal 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)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
15
apps/mcp/src/widget/lib/readFileAsBase64.ts
Normal file
15
apps/mcp/src/widget/lib/readFileAsBase64.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
}
|
||||
],
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue