Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions apps/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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.
1 change: 1 addition & 0 deletions apps/mcp/e2e/discovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions apps/mcp/src/server/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}

Expand Down
40 changes: 40 additions & 0 deletions apps/mcp/src/server/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
containerTagSchema,
documentsApiResponseSchema,
memoriesListSchema,
uploadResponseSchema,
type ContainerTag,
type DocumentMemoryEntry,
type DocumentsApiResponse,
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions apps/mcp/src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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
5 changes: 0 additions & 5 deletions apps/mcp/src/server/legacy-protocol-state.ts

This file was deleted.

6 changes: 6 additions & 0 deletions apps/mcp/src/server/resources/widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", {
Expand Down
11 changes: 10 additions & 1 deletion apps/mcp/src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
2 changes: 2 additions & 0 deletions apps/mcp/src/server/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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 changes: 66 additions & 0 deletions apps/mcp/src/server/tools/upload-file-submit.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 15 additions & 0 deletions apps/mcp/src/widget/lib/readFileAsBase64.ts
Original file line number Diff line number Diff line change
@@ -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)
})
}
98 changes: 60 additions & 38 deletions apps/mcp/src/widget/views/Upload.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useMemo, useState } from "react"
import {
uploadPreparationSchema,
uploadResponseSchema,
uploadSuccessViewSchema,
type ViewMessage,
} from "../../shared/types"
import {
Expand All @@ -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
Expand Down Expand Up @@ -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 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 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 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 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,
},
},
Expand Down
Loading
Loading