fix MCP app result contracts (#1385)

Moves MCP App results onto explicit runtime schemas so hosts can distinguish model-visible output from widget-only graph data.

- advertises output schemas for structured tools
- validates API, session, and widget boundaries instead of asserting response types
- marks additive app writes as non-destructive and removes redundant widget typing

Verified with MCP typecheck, 42 unit tests, and the production widget build. Claude Desktop host validation is in progress against a temporary tunnel.
This commit is contained in:
Prasanna721 2026-07-31 18:23:29 +00:00
parent e8ed80f768
commit 9e194fbc50
35 changed files with 512 additions and 398 deletions

View file

@ -1,4 +1,5 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest"
import { graphViewSchema } from "../src/shared/types"
import {
OAUTH_CREDENTIALS_AVAILABLE,
callTool,
@ -18,17 +19,16 @@ describeWithAuth("MCP — graph, resources & prompts", () => {
await s?.close()
})
it("memory-graph returns a summary + structured documents", async () => {
it("memory-graph returns a rendered widget summary", async () => {
const res = await callTool(s.client, "memory-graph")
expect(res.isError).toBeFalsy()
expect(textOf(res)).toMatch(
/Rendered the interactive Memory Graph MCP App: \d+ documents/,
/The interactive Memory Graph MCP App is rendered and visible: \d+ documents/,
)
const sc = res.structuredContent as {
documents?: unknown[]
totalCount?: number
}
expect(Array.isArray(sc?.documents)).toBe(true)
const result = graphViewSchema.safeParse(res.structuredContent)
expect(result.success).toBe(true)
if (!result.success) throw result.error
expect(result.data.rendered).toBe(true)
})
it("fetch-graph-data returns paginated documents", async () => {

View file

@ -1,5 +1,5 @@
import { createRemoteJWKSet, jwtVerify, type JWTVerifyGetKey } from "jose"
import type { SessionInfo } from "../../shared/types"
import { sessionInfoSchema, type SessionInfo } from "../../shared/types"
const FETCH_TIMEOUT_MS = 30_000
@ -44,12 +44,12 @@ export async function fetchSession(
)
}
const session = (await response.json()) as SessionInfo | null
if (!session?.user?.id) {
throw new Error("Missing user.id in session response")
const result = sessionInfoSchema.safeParse(await response.json())
if (!result.success) {
throw new Error("Invalid session response")
}
return session
return result.data
}
export async function validateOAuthToken(

View file

@ -3,11 +3,15 @@ import type {
DocumentGetResponse,
DocumentListResponse as SdkDocumentListResponse,
} from "supermemory/resources/documents"
import type {
ContainerTag,
DocumentMemoryEntry,
DocumentsApiResponse,
DocumentWithMemories,
import { z } from "zod"
import {
containerTagSchema,
documentsApiResponseSchema,
paginationSchema,
type ContainerTag,
type DocumentMemoryEntry,
type DocumentsApiResponse,
type DocumentWithMemories,
} from "../../shared/types"
const MAX_CHARS = 200000
@ -30,42 +34,43 @@ export interface DocumentsListResponse {
pagination: SdkDocumentListResponse["pagination"]
}
export interface MemoryEntryHistory {
id: string
memory: string
version: number
createdAt: string
updatedAt: string
parentMemoryId?: string | null
rootMemoryId?: string | null
isLatest?: boolean
isForgotten?: boolean
}
const memoryEntryHistorySchema = z.looseObject({
id: z.string(),
memory: z.string(),
version: z.number(),
createdAt: z.string(),
updatedAt: z.string(),
parentMemoryId: z.string().nullish(),
rootMemoryId: z.string().nullish(),
isLatest: z.boolean().optional(),
isForgotten: z.boolean().optional(),
})
export interface MemoryEntry {
id: string
memory: string
version: number
isLatest: boolean
isForgotten: boolean
isStatic?: boolean
isInference?: boolean
createdAt: string
updatedAt: string
sourceCount?: number
documentIds?: string[]
history?: MemoryEntryHistory[]
}
export type MemoryEntryHistory = z.infer<typeof memoryEntryHistorySchema>
export interface MemoryEntriesResponse {
memoryEntries: MemoryEntry[]
pagination: {
currentPage: number
limit: number
totalItems: number
totalPages: number
}
}
const memoryEntrySchema = z.looseObject({
id: z.string(),
memory: z.string(),
version: z.number(),
isLatest: z.boolean(),
isForgotten: z.boolean(),
isStatic: z.boolean().optional(),
isInference: z.boolean().optional(),
createdAt: z.string(),
updatedAt: z.string(),
sourceCount: z.number().optional(),
documentIds: z.array(z.string()).optional(),
history: z.array(memoryEntryHistorySchema).optional(),
})
export type MemoryEntry = z.infer<typeof memoryEntrySchema>
const memoryEntriesResponseSchema = z.object({
memoryEntries: z.array(memoryEntrySchema),
pagination: paginationSchema,
})
export type MemoryEntriesResponse = z.infer<typeof memoryEntriesResponseSchema>
export type Memory =
| {
@ -107,14 +112,46 @@ function limitByChars(text: string, maxChars = MAX_CHARS): string {
return text.length > maxChars ? `${text.slice(0, maxChars)}...` : text
}
interface SDKResult {
id: string
memory?: string
chunk?: string
content?: string
similarity: number
title?: string
context?: string
const sdkResultSchema = z.looseObject({
id: z.string(),
memory: z.string().nullish(),
chunk: z.string().nullish(),
content: z.string().nullish(),
similarity: z.number(),
title: z.string().nullish(),
context: z.string().nullish(),
})
const uploadResultSchema = z.object({
id: z.string(),
status: z.string(),
})
function mapSdkResults(value: unknown): Memory[] {
return z
.array(sdkResultSchema)
.parse(value)
.map((result) => {
const text = limitByChars(
result.content || result.memory || result.chunk || result.context || "",
)
const base = {
id: result.id,
similarity: result.similarity,
...(result.title ? { title: result.title } : {}),
...(result.content ? { content: result.content } : {}),
}
if (result.chunk && !result.memory) {
return { ...base, chunk: text }
}
return { ...base, memory: text }
})
}
function objectProperty(value: unknown, key: string): unknown {
return value && typeof value === "object"
? Reflect.get(value, key)
: undefined
}
export class SupermemoryClient {
@ -175,10 +212,7 @@ export class SupermemoryClient {
containerTag: this.containerTag,
}
} catch (error: unknown) {
const status =
error && typeof error === "object" && "status" in error
? (error as Record<string, unknown>).status
: undefined
const status = objectProperty(error, "status")
if (status !== 404) throw error
}
@ -242,23 +276,11 @@ export class SupermemoryClient {
threshold,
})
const results: Memory[] = (result.results as SDKResult[]).map((r) => {
const text = limitByChars(
r.content || r.memory || r.chunk || r.context || "",
)
const base = {
id: r.id,
similarity: r.similarity,
title: r.title,
content: r.content,
}
if (r.chunk && !r.memory) {
return { ...base, chunk: text }
}
return { ...base, memory: text }
})
return { results, total: result.total, timing: result.timing }
return {
results: mapSdkResults(result.results),
total: result.total,
timing: result.timing,
}
} catch (error) {
this.handleOperationError("Search request", error)
}
@ -289,19 +311,7 @@ export class SupermemoryClient {
if (result.searchResults) {
response.searchResults = {
results: (result.searchResults.results as SDKResult[]).map((r) => {
const text = limitByChars(
r.content || r.memory || r.chunk || r.context || "",
)
const base = {
id: r.id,
similarity: r.similarity,
title: r.title,
content: r.content,
}
if (r.chunk && !r.memory) return { ...base, chunk: text }
return { ...base, memory: text }
}),
results: mapSdkResults(result.searchResults.results),
total: result.searchResults.total,
timing: result.searchResults.timing,
}
@ -335,8 +345,7 @@ export class SupermemoryClient {
)
}
const data = (await response.json()) as ContainerTag[]
return Array.isArray(data) ? data : []
return z.array(containerTagSchema).parse(await response.json())
} catch (error) {
this.handleError(error)
}
@ -371,7 +380,7 @@ export class SupermemoryClient {
status: response.status,
})
}
return (await response.json()) as DocumentsApiResponse
return documentsApiResponseSchema.parse(await response.json())
} catch (error) {
this.handleError(error)
}
@ -435,7 +444,7 @@ export class SupermemoryClient {
)
}
return (await response.json()) as MemoryEntriesResponse
return memoryEntriesResponseSchema.parse(await response.json())
} catch (error) {
this.handleError(error)
}
@ -472,8 +481,7 @@ export class SupermemoryClient {
})
}
const result = (await response.json()) as { id: string; status: string }
return result
return uploadResultSchema.parse(await response.json())
} catch (error) {
this.handleError(error)
}
@ -498,11 +506,10 @@ export class SupermemoryClient {
}
}
if (error && typeof error === "object" && "status" in error) {
const status = (error as { status: number }).status
const message =
"message" in error ? (error as { message: string }).message : undefined
const status = objectProperty(error, "status")
if (typeof status === "number") {
const rawMessage = objectProperty(error, "message")
const message = typeof rawMessage === "string" ? rawMessage : undefined
switch (status) {
case 400:
case 422:

View file

@ -9,5 +9,5 @@ export const containerTagSchema = z
export const optionalContainerTagSchema = containerTagSchema
.optional()
.describe(
"Space key to use for this call. If the user names a space, call listSpaces to resolve its key and pass it here. Omit only when the user means the active space.",
"Space key to use for this call. If the user names a space, call listSpaces to resolve its key and pass it here. If no space is named, omit this field so the server uses the active space or account default.",
)

View file

@ -2,7 +2,6 @@ import type { AuthInfo } from "@modelcontextprotocol/server"
import { createMcpHandler } from "agents/mcp/server"
import { Hono, type Context } from "hono"
import { cors } from "hono/cors"
import type { ContentfulStatusCode } from "hono/utils/http-status"
import { validateOAuthToken, type AuthUser } from "./auth"
import { SupermemoryMCP } from "./legacy-protocol-state"
import { createSupermemoryServer } from "./server"
@ -82,9 +81,9 @@ app.get("/.well-known/oauth-authorization-server", async (c) => {
`${apiUrl}/.well-known/oauth-authorization-server`,
)
if (!response.ok) {
return c.json(
return Response.json(
{ error: "Failed to fetch authorization server metadata" },
{ status: response.status as ContentfulStatusCode },
{ status: response.status },
)
}
return c.json(await response.json())

View file

@ -7,7 +7,10 @@ const CSP_DOMAINS = [
"https://esm.sh",
"https://fonts.googleapis.com",
"https://fonts.gstatic.com",
] as const
]
const WIDGET_DESCRIPTION =
"Interactive Supermemory view. The tool result identifies whether the mounted app is a memory graph, space picker, save form, upload form, or confirmation. When rendered is true, the interface is already visible to the user."
const RESOURCE_UI_META = {
prefersBorder: true,
@ -17,6 +20,11 @@ const RESOURCE_UI_META = {
},
}
const RESOURCE_META = {
ui: RESOURCE_UI_META,
"openai/widgetDescription": WIDGET_DESCRIPTION,
}
export function registerWidgetResource(server: McpServer) {
server.registerResource(
"Supermemory MCP UI",
@ -26,7 +34,8 @@ export function registerWidgetResource(server: McpServer) {
// so prefetch/connect-time decisions match what the host will get.
{
mimeType: APP_RESOURCE_MIME_TYPE,
_meta: { ui: RESOURCE_UI_META },
description: WIDGET_DESCRIPTION,
_meta: 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
@ -37,7 +46,7 @@ export function registerWidgetResource(server: McpServer) {
uri: SUPERMEMORY_RESOURCE_URI,
mimeType: APP_RESOURCE_MIME_TYPE,
text: supermemoryAppHtml,
_meta: { ui: RESOURCE_UI_META },
_meta: RESOURCE_META,
},
],
}),

View file

@ -23,14 +23,15 @@ import {
} from "./space"
const DEFAULT_API_URL = "https://api.supermemory.ai"
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."
type ClientInfo = { name: string; version?: string }
function clientInfoFromContext(context: ServerContext): ClientInfo | null {
const envelope = context.mcpReq.envelope as
| Record<string, unknown>
| undefined
const value = envelope?.[CLIENT_INFO_META_KEY]
const envelope = context.mcpReq.envelope
if (!envelope) return null
const value = Reflect.get(envelope, CLIENT_INFO_META_KEY)
if (!value || typeof value !== "object") return null
const name = Reflect.get(value, "name")
@ -48,10 +49,13 @@ export function createSupermemoryServer(
actor: ActorContext,
waitUntil: WaitUntil,
): McpServer {
const server = new McpServer({
name: "supermemory",
version: "1.0.0",
})
const server = new McpServer(
{
name: "supermemory",
version: "1.0.0",
},
{ instructions: SERVER_INSTRUCTIONS },
)
const apiUrl = env.API_URL || DEFAULT_API_URL
const spaceState = env.SPACE_STATE.getByName(spaceStateName(actor))

View file

@ -40,7 +40,7 @@ export function formatActivityDate(value: string | null): string | undefined {
}
export function spaceMetadata(space: ContainerTag): string {
const lastActivity = formatActivityDate(space.lastActivityAt)
const lastActivity = formatActivityDate(space.lastActivityAt ?? null)
const fields = [
space.visibility
? `${space.visibility.charAt(0).toUpperCase()}${space.visibility.slice(1)}`

View file

@ -4,14 +4,21 @@ export const READ_ONLY_TOOL_ANNOTATIONS = {
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
} as const
}
export const MEMORY_TOOL_ANNOTATIONS = {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: false,
openWorldHint: false,
} as const
}
export const ADDITIVE_MEMORY_TOOL_ANNOTATIONS = {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: false,
}
/** Non-destructive account/session mutations, e.g. switching the active space. */
export const SETTINGS_TOOL_ANNOTATIONS = {
@ -19,4 +26,4 @@ export const SETTINGS_TOOL_ANNOTATIONS = {
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
} as const
}

View file

@ -1,4 +1,5 @@
import { z } from "zod"
import { documentsApiResponseSchema } from "../../shared/types"
import { appToolMeta } from "../app-metadata"
import { optionalContainerTagSchema } from "../container-tag"
import { READ_ONLY_TOOL_ANNOTATIONS } from "./annotations"
@ -14,6 +15,7 @@ export function register(deps: ToolDeps) {
page: z.number().optional().default(1),
limit: z.number().optional().default(200),
}),
outputSchema: documentsApiResponseSchema,
annotations: READ_ONLY_TOOL_ANNOTATIONS,
_meta: appToolMeta(["app"]),
},

View file

@ -1,5 +1,5 @@
import { z } from "zod"
import type { ViewMessage } from "../../shared/types"
import { saveViewSchema, type ViewMessage } from "../../shared/types"
import { appResultMeta, appToolMeta } from "../app-metadata"
import { effectiveContainerTagAccess } from "../auth/rbac"
import { READ_ONLY_TOOL_ANNOTATIONS } from "./annotations"
@ -10,10 +10,12 @@ export function register(deps: ToolDeps) {
"guided-save",
{
title: "Add Memory",
description: "Save information to memory with an interactive form.",
description:
"Open an interactive form when the user wants to draft, review, edit, or choose the target space before saving information to Supermemory. Use this when the user wants to add a memory but has not supplied final content, or explicitly wants to review supplied content before saving. If the user provides the exact content and asks to save it immediately, use add_memory instead.",
inputSchema: z.object({
prefill: z.string().optional().describe("Optional content to prefill"),
}),
outputSchema: saveViewSchema,
_meta: appToolMeta(),
annotations: READ_ONLY_TOOL_ANNOTATIONS,
},

View file

@ -1,4 +1,5 @@
import { z } from "zod"
import { listSpacesOutputSchema } from "../../shared/types"
import { READ_ONLY_TOOL_ANNOTATIONS } from "./annotations"
import type { ToolDeps } from "./types"
@ -9,11 +10,22 @@ export function register(deps: ToolDeps) {
description:
"List the spaces available to the user. Returns each space's name, key, emoji, document/memory counts, and last activity. Use this first to resolve a named space before calling a space-aware tool, or when the user asks which space may contain something. The list is auto-filtered to spaces the user can access.",
inputSchema: z.object({}),
outputSchema: listSpacesOutputSchema,
annotations: READ_ONLY_TOOL_ANNOTATIONS,
},
async () => {
try {
const tags = await deps.getClient().listContainerTags()
const spaces = tags.map((tag) => ({
name: tag.name,
containerTag: tag.containerTag,
description: tag.description,
visibility: tag.visibility,
emoji: tag.emoji,
documentCount: tag.documentCount,
memoryCount: tag.memoryCount,
lastActivityAt: tag.lastActivityAt,
}))
if (tags.length === 0) {
return {
@ -23,6 +35,7 @@ export function register(deps: ToolDeps) {
text: "No spaces found.",
},
],
structuredContent: { spaces, count: 0 },
}
}
@ -39,7 +52,7 @@ export function register(deps: ToolDeps) {
text: `Available spaces:\n${lines.join("\n")}`,
},
],
structuredContent: { containerTags: tags },
structuredContent: { spaces, count: spaces.length },
}
} catch (error) {
return deps.errorResult(error)

View file

@ -1,6 +1,6 @@
import { z } from "zod"
import type { ViewMessage } from "../../shared/types"
import { appToolMeta } from "../app-metadata"
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"
import type { ToolDeps } from "./types"
@ -15,13 +15,15 @@ export function register(deps: ToolDeps) {
{
title: "Memory Graph",
description:
"Render the space's memory graph directly as an interactive MCP App. This tool is the final visualization; do not create another graph, file, or artifact unless the user explicitly asks for one. When the user names a space, resolve it with listSpaces and pass containerTag.",
"Render a space's memory graph directly as an interactive MCP App. This tool is the final visualization; do not create another graph, file, or artifact unless the user explicitly asks for one. If the user names a space, call listSpaces to resolve its key and pass it as containerTag. If the user does not name a space, call this tool directly and omit containerTag; the server uses the active space or account default. Do not open the space picker unless the user asks to change their active space.",
inputSchema,
outputSchema: graphViewSchema,
annotations: READ_ONLY_TOOL_ANNOTATIONS,
_meta: appToolMeta(),
},
async (args) => {
try {
const viewId = crypto.randomUUID()
const effectiveTag = await deps.resolveContainerTag(args.containerTag)
const client = deps.getClient(effectiveTag)
const containerTags = effectiveTag ? [effectiveTag] : undefined
@ -35,19 +37,27 @@ export function register(deps: ToolDeps) {
const sc: ViewMessage = {
view: "graph",
containerTag: effectiveTag,
documents: result.documents,
totalCount: result.pagination.totalItems,
viewId,
...(effectiveTag ? { containerTag: effectiveTag } : {}),
documentCount: result.documents.length,
memoryCount,
totalDocumentCount: result.pagination.totalItems,
truncated: result.documents.length < result.pagination.totalItems,
rendered: true,
}
return {
content: [
{
type: "text" as const,
text: `Rendered the interactive Memory Graph MCP App: ${result.documents.length} documents, ${memoryCount} memories${effectiveTag ? `. Space: ${effectiveTag}` : ""}. Do not create a duplicate graph or artifact unless the user explicitly requests one.`,
text: `The interactive Memory Graph MCP App is rendered and visible: ${result.documents.length} documents, ${memoryCount} memories${effectiveTag ? `. Space: ${effectiveTag}` : ""}. Do not create a duplicate graph or artifact unless the user explicitly requests one.`,
},
],
structuredContent: sc,
_meta: {
...appResultMeta(viewId),
graphData: { documents: result.documents },
},
}
} catch (error) {
return deps.errorResult(error)

View file

@ -1,8 +1,8 @@
import { z } from "zod"
import type { ViewMessage } from "../../shared/types"
import { saveSuccessViewSchema, type ViewMessage } from "../../shared/types"
import { appResultMeta, appToolMeta } from "../app-metadata"
import { containerTagSchema } from "../container-tag"
import { MEMORY_TOOL_ANNOTATIONS } from "./annotations"
import { ADDITIVE_MEMORY_TOOL_ANNOTATIONS } from "./annotations"
import type { ToolDeps } from "./types"
export function register(deps: ToolDeps) {
@ -15,7 +15,8 @@ export function register(deps: ToolDeps) {
containerTag: containerTagSchema,
viewId: z.string().uuid().optional(),
}),
annotations: MEMORY_TOOL_ANNOTATIONS,
outputSchema: saveSuccessViewSchema,
annotations: ADDITIVE_MEMORY_TOOL_ANNOTATIONS,
_meta: appToolMeta(["app"]),
},
async (args) => {

View file

@ -1,5 +1,5 @@
import { z } from "zod"
import type { ViewMessage } from "../../shared/types"
import { pickerViewSchema, type ViewMessage } from "../../shared/types"
import { appResultMeta, appToolMeta } from "../app-metadata"
import { effectiveContainerTagAccess } from "../auth/rbac"
import { READ_ONLY_TOOL_ANNOTATIONS } from "./annotations"
@ -11,8 +11,9 @@ export function register(deps: ToolDeps) {
{
title: "Select Space",
description:
"Choose the active Supermemory space. Shows available spaces as interactive cards.",
"Open an interactive picker to choose or change the active Supermemory space used for future actions. Use this only when the user asks to switch, select, or change their active or default space. Do not use it merely because the user names a space for a search, list, graph, save, or upload; resolve that space with listSpaces and pass containerTag to the relevant tool instead.",
inputSchema: z.object({}),
outputSchema: pickerViewSchema,
_meta: appToolMeta(),
annotations: READ_ONLY_TOOL_ANNOTATIONS,
},

View file

@ -1,5 +1,5 @@
import { z } from "zod"
import type { ViewMessage } from "../../shared/types"
import { confirmationViewSchema, type ViewMessage } from "../../shared/types"
import { appResultMeta, appToolMeta } from "../app-metadata"
import { containerTagSchema } from "../container-tag"
import { SETTINGS_TOOL_ANNOTATIONS } from "./annotations"
@ -14,6 +14,7 @@ export function register(deps: ToolDeps) {
containerTag: containerTagSchema,
viewId: z.string().uuid().optional(),
}),
outputSchema: confirmationViewSchema,
_meta: appToolMeta(["app"]),
annotations: SETTINGS_TOOL_ANNOTATIONS,
},

View file

@ -1,8 +1,8 @@
import { z } from "zod"
import type { ViewMessage } from "../../shared/types"
import { uploadSuccessViewSchema, type ViewMessage } from "../../shared/types"
import { appResultMeta, appToolMeta } from "../app-metadata"
import { containerTagSchema } from "../container-tag"
import { MEMORY_TOOL_ANNOTATIONS } from "./annotations"
import { ADDITIVE_MEMORY_TOOL_ANNOTATIONS } from "./annotations"
import type { ToolDeps } from "./types"
export function register(deps: ToolDeps) {
@ -17,7 +17,8 @@ export function register(deps: ToolDeps) {
containerTag: containerTagSchema,
viewId: z.string().uuid().optional(),
}),
annotations: MEMORY_TOOL_ANNOTATIONS,
outputSchema: uploadSuccessViewSchema,
annotations: ADDITIVE_MEMORY_TOOL_ANNOTATIONS,
_meta: appToolMeta(["app"]),
},
async (args) => {
@ -31,7 +32,7 @@ export function register(deps: ToolDeps) {
const client = deps.getClient(args.containerTag)
const result = await client.uploadFile(
bytes.buffer as ArrayBuffer,
bytes.buffer,
args.fileName,
args.mimeType,
args.containerTag,

View file

@ -1,5 +1,5 @@
import { z } from "zod"
import type { ViewMessage } from "../../shared/types"
import { uploadViewSchema, type ViewMessage } from "../../shared/types"
import { appResultMeta, appToolMeta } from "../app-metadata"
import { effectiveContainerTagAccess } from "../auth/rbac"
import { READ_ONLY_TOOL_ANNOTATIONS } from "./annotations"
@ -10,8 +10,10 @@ export function register(deps: ToolDeps) {
"upload-file",
{
title: "Upload File",
description: "Upload a file (PDF, text, image, video) to memory.",
description:
"Open Supermemory's interactive file picker whenever the user wants to upload, import, or add any local file to Supermemory. Call this tool immediately even when the user only says they want to upload a file. Do not ask for a file path, folder, filename, or filesystem access; the picker handles file selection. It supports documents, text, spreadsheets, images, audio, and video.",
inputSchema: z.object({}),
outputSchema: uploadViewSchema,
_meta: appToolMeta(),
annotations: READ_ONLY_TOOL_ANNOTATIONS,
},

View file

@ -6,7 +6,8 @@ export function register(deps: ToolDeps) {
deps.server.registerTool(
"whoAmI",
{
description: "Get current user info, role, and space context",
description:
"Get the current Supermemory account context, including user identity, role, access type, permissions, scope, and active space. Use this when the user asks who they are, what access they have, or which space is currently active. Use listSpaces instead when the user asks which spaces are available.",
inputSchema: z.object({}),
annotations: READ_ONLY_TOOL_ANNOTATIONS,
},

View file

@ -1,131 +1,200 @@
import { z } from "zod"
// Shared types — imported by both server tools and widget views.
// Single source of truth for the server↔widget contract.
export interface ContainerTagAccess {
containerTag: string
permission: "read" | "write"
}
export const containerTagAccessSchema = z.object({
containerTag: z.string(),
permission: z.enum(["read", "write"]),
})
export interface SessionScope {
type: "full" | "scoped"
permission?: "read" | "write"
tag?: string
tags?: string[]
rateLimit?: number
expires?: string
}
export type ContainerTagAccess = z.infer<typeof containerTagAccessSchema>
export interface SessionInfo {
user: {
id: string
email?: string
name?: string
}
role?: string
accessType?: "full" | "restricted"
containerTags?: ContainerTagAccess[] | null
scope?: SessionScope
}
export const sessionScopeSchema = z.looseObject({
type: z.enum(["full", "scoped"]),
permission: z.enum(["read", "write"]).optional(),
tag: z.string().optional(),
tags: z.array(z.string()).optional(),
rateLimit: z.number().optional(),
expires: z.string().optional(),
})
export interface ContainerTag {
id: string
name: string
containerTag: string
description?: string | null
visibility?: string | null
createdAt: string
updatedAt: string
isExperimental: boolean
emoji?: string
isNova: boolean
documentCount: number
memoryCount: number
lastActivityAt: string | null
}
export type SessionScope = z.infer<typeof sessionScopeSchema>
export interface DocumentMemoryEntry {
id: string
memory: string
spaceId: string
isStatic?: boolean
isLatest?: boolean
isForgotten?: boolean
forgetAfter?: string | null
forgetReason?: string | null
version?: number
parentMemoryId?: string | null
rootMemoryId?: string | null
memoryRelations?: Record<string, string>
createdAt: string
updatedAt: string
}
export const sessionInfoSchema = z.looseObject({
user: z.looseObject({
id: z.string().min(1),
email: z.string().optional(),
name: z.string().optional(),
}),
role: z.string().optional(),
accessType: z.enum(["full", "restricted"]).optional(),
containerTags: z.array(containerTagAccessSchema).nullable().optional(),
scope: sessionScopeSchema.optional(),
})
export interface DocumentWithMemories {
id: string
title: string | null
summary?: string | null
type: string
createdAt: string
updatedAt: string
memoryEntries: DocumentMemoryEntry[]
}
export type SessionInfo = z.infer<typeof sessionInfoSchema>
export interface DocumentsApiResponse {
documents: DocumentWithMemories[]
pagination: {
currentPage: number
limit: number
totalItems: number
totalPages: number
}
}
export const containerTagSchema = z.looseObject({
id: z.string(),
name: z.string(),
containerTag: z.string(),
description: z.string().nullish(),
visibility: z.string().nullish(),
createdAt: z.string(),
updatedAt: z.string(),
isExperimental: z.boolean(),
emoji: z.string().nullish(),
isNova: z.boolean(),
documentCount: z.number().int().nonnegative(),
memoryCount: z.number().int().nonnegative(),
lastActivityAt: z.string().nullish(),
})
export type ContainerTag = z.infer<typeof containerTagSchema>
export const spaceSummarySchema = z.object({
name: z.string(),
containerTag: z.string(),
description: z.string().nullish(),
visibility: z.string().nullish(),
emoji: z.string().nullish(),
documentCount: z.number().int().nonnegative(),
memoryCount: z.number().int().nonnegative(),
lastActivityAt: z.string().nullish(),
})
export const listSpacesOutputSchema = z.object({
spaces: z.array(spaceSummarySchema),
count: z.number().int().nonnegative(),
})
export const memoryRelationSchema = z.enum(["updates", "extends", "derives"])
export const documentMemoryEntrySchema = z.looseObject({
id: z.string(),
memory: z.string(),
spaceId: z.string(),
isStatic: z.boolean().nullish(),
isLatest: z.boolean().nullish(),
isForgotten: z.boolean().nullish(),
forgetAfter: z.string().nullish(),
forgetReason: z.string().nullish(),
version: z.number().nullish(),
parentMemoryId: z.string().nullish(),
rootMemoryId: z.string().nullish(),
memoryRelations: z.record(z.string(), memoryRelationSchema).nullish(),
createdAt: z.string(),
updatedAt: z.string(),
})
export type DocumentMemoryEntry = z.infer<typeof documentMemoryEntrySchema>
export const documentWithMemoriesSchema = z.looseObject({
id: z.string(),
title: z.string().nullable(),
summary: z.string().nullish(),
type: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
memoryEntries: z.array(documentMemoryEntrySchema),
})
export type DocumentWithMemories = z.infer<typeof documentWithMemoriesSchema>
export const paginationSchema = z.object({
currentPage: z.number().int().nonnegative(),
limit: z.number().int().nonnegative(),
totalItems: z.number().int().nonnegative(),
totalPages: z.number().int().nonnegative(),
})
export const documentsApiResponseSchema = z.object({
documents: z.array(documentWithMemoriesSchema),
pagination: paginationSchema,
})
export type DocumentsApiResponse = z.infer<typeof documentsApiResponseSchema>
// ViewMessage — discriminated union returned by app tools as `structuredContent`.
// The widget uses an exhaustive switch on `view` to dispatch to the correct view component.
// Adding a new view here is a compile error in App.tsx until the case is handled.
type ViewMessagePayload =
| {
view: "picker"
containerTags: ContainerTag[]
activeTag?: string | null
assignedTags?: ContainerTagAccess[] | null
}
| { view: "confirmation"; containerTag: string }
| {
view: "save"
activeTag?: string | null
writableTags: string[]
prefill?: string
}
| { view: "save-success"; id: string; containerTag: string }
| {
view: "upload"
activeTag?: string | null
writableTags: string[]
}
| {
view: "upload-success"
id: string
fileName: string
containerTag: string
}
| {
view: "graph"
documents: DocumentWithMemories[]
totalCount: number
containerTag?: string
}
const viewIdSchema = z.string().uuid().optional()
export type ViewMessage = ViewMessagePayload & {
/**
* Stable identity for one rendered widget instance.
*
* The host may remount the iframe when a conversation is revisited. The
* widget uses this id to restore a completed local view without treating UI
* state as the source of truth for the underlying Supermemory write.
*/
viewId?: string
}
export const pickerViewSchema = z.object({
view: z.literal("picker"),
viewId: viewIdSchema,
containerTags: z.array(containerTagSchema),
activeTag: z.string().nullish(),
assignedTags: z.array(containerTagAccessSchema).nullable().optional(),
})
export const confirmationViewSchema = z.object({
view: z.literal("confirmation"),
viewId: viewIdSchema,
containerTag: z.string(),
})
export const saveViewSchema = z.object({
view: z.literal("save"),
viewId: viewIdSchema,
activeTag: z.string().nullish(),
writableTags: z.array(z.string()),
prefill: z.string().optional(),
})
export const saveSuccessViewSchema = z.object({
view: z.literal("save-success"),
viewId: viewIdSchema,
id: z.string(),
containerTag: z.string(),
})
export const uploadViewSchema = z.object({
view: z.literal("upload"),
viewId: viewIdSchema,
activeTag: z.string().nullish(),
writableTags: z.array(z.string()),
})
export const uploadSuccessViewSchema = z.object({
view: z.literal("upload-success"),
viewId: viewIdSchema,
id: z.string(),
fileName: z.string(),
containerTag: z.string(),
})
export const graphViewSchema = z.object({
view: z.literal("graph"),
viewId: viewIdSchema,
containerTag: z.string().optional(),
documentCount: z.number().int().nonnegative(),
memoryCount: z.number().int().nonnegative(),
totalDocumentCount: z.number().int().nonnegative(),
truncated: z.boolean(),
rendered: z.literal(true),
})
export const viewMessageSchema = z.discriminatedUnion("view", [
pickerViewSchema,
confirmationViewSchema,
saveViewSchema,
saveSuccessViewSchema,
uploadViewSchema,
uploadSuccessViewSchema,
graphViewSchema,
])
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,5 +1,5 @@
import { type ReactNode, useEffect } from "react"
import type { ViewMessage } from "../shared/types"
import type { GraphResultMeta, ViewMessage } from "../shared/types"
import { useApplyHostTheme } from "./hooks/useApplyHostTheme"
import { useLog } from "./hooks/useLog"
import { useViewState } from "./hooks/useViewState"
@ -50,7 +50,7 @@ export function App() {
const isGraphView = state.message.view === "graph"
return (
<WidgetShell immersive={isGraphView}>
{renderView(state.message, setView, setError)}
{renderView(state.message, state.resultMeta, setView, setError)}
</WidgetShell>
)
}
@ -93,6 +93,7 @@ export function WidgetShell({
function renderView(
msg: ViewMessage,
resultMeta: GraphResultMeta | undefined,
setView: (m: ViewMessage) => void,
setError: (m: string) => void,
) {
@ -130,11 +131,14 @@ function renderView(
/>
)
case "graph":
if (!resultMeta) {
return <ErrorView message="Memory graph data is unavailable." />
}
return (
<Graph
containerTag={msg.containerTag}
documents={msg.documents}
totalCount={msg.totalCount}
documents={resultMeta.graphData.documents}
totalCount={msg.totalDocumentCount}
/>
)
case "confirmation":

View file

@ -3,7 +3,6 @@ import type {
McpUiHostContext,
} from "@modelcontextprotocol/ext-apps"
import { useApp as useMcpApp } from "@modelcontextprotocol/ext-apps/react"
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"
import {
createContext,
type ReactNode,
@ -11,12 +10,17 @@ import {
useMemo,
useState,
} from "react"
import type { ViewMessage } from "../shared/types"
import {
graphResultMetaSchema,
type GraphResultMeta,
viewMessageSchema,
type ViewMessage,
} from "../shared/types"
import { loadViewCheckpoint, saveViewCheckpoint } from "./lib/viewCheckpoint"
export type ViewState =
| { kind: "loading" }
| { kind: "view"; message: ViewMessage }
| { kind: "view"; message: ViewMessage; resultMeta?: GraphResultMeta }
| { kind: "error"; message: string }
| { kind: "raw"; structuredContent: unknown }
@ -61,12 +65,8 @@ export function McpAppProvider({ children }: { children: ReactNode }) {
capabilities: {},
strict: true,
onAppCreated: (createdApp) => {
createdApp.ontoolinput = (input: unknown) => {
const name =
typeof input === "object" && input !== null && "name" in input
? String((input as { name: unknown }).name)
: "?"
safeLog(createdApp, "info", `[host] ontoolinput: ${name}`)
createdApp.ontoolinput = () => {
safeLog(createdApp, "info", "[host] ontoolinput")
setState({ kind: "loading" })
}
createdApp.ontoolinputpartial = () => setState({ kind: "loading" })
@ -74,35 +74,45 @@ export function McpAppProvider({ children }: { children: ReactNode }) {
safeLog(createdApp, "info", "[host] ontoolcancelled")
setState({ kind: "loading" })
}
createdApp.ontoolresult = (result: CallToolResult) => {
const structuredContent = (result as { structuredContent?: unknown })
.structuredContent
if (!structuredContent || typeof structuredContent !== "object") {
createdApp.ontoolresult = (result) => {
const structuredContent = result.structuredContent
const parsedMessage = viewMessageSchema.safeParse(structuredContent)
if (!parsedMessage.success) {
safeLog(
createdApp,
"warning",
"[host] ontoolresult: no structuredContent",
"[host] ontoolresult: invalid structuredContent",
)
setState({ kind: "raw", structuredContent })
return
}
if ("view" in structuredContent) {
const message = structuredContent as ViewMessage
safeLog(
createdApp,
"info",
`[host] ontoolresult: view=${message.view}`,
)
const checkpoint = loadViewCheckpoint(message.viewId)
setState({ kind: "view", message: checkpoint ?? message })
const message = parsedMessage.data
safeLog(createdApp, "info", `[host] ontoolresult: view=${message.view}`)
const checkpoint = loadViewCheckpoint(message.viewId)
if (checkpoint) {
setState({ kind: "view", message: checkpoint })
return
}
safeLog(
createdApp,
"warning",
"[host] ontoolresult: structuredContent without view",
)
setState({ kind: "raw", structuredContent })
if (message.view === "graph") {
const parsedMeta = graphResultMetaSchema.safeParse(result._meta)
if (!parsedMeta.success) {
setState({
kind: "error",
message: "Memory graph data is unavailable.",
})
return
}
setState({
kind: "view",
message,
resultMeta: parsedMeta.data,
})
return
}
setState({ kind: "view", message })
}
createdApp.onhostcontextchanged = (next) => {
setHostContext(createdApp.getHostContext() ?? next)

View file

@ -1,9 +1,5 @@
import { cva, type VariantProps } from "class-variance-authority"
import {
type ButtonHTMLAttributes,
forwardRef,
type HTMLAttributes,
} from "react"
import { forwardRef, type HTMLAttributes } from "react"
import { cn } from "../lib/cn"
const cardStyles = cva(
@ -28,38 +24,15 @@ const cardStyles = cva(
type CardVariantProps = VariantProps<typeof cardStyles>
type DivProps = HTMLAttributes<HTMLDivElement> &
CardVariantProps & { as?: "div" }
export type CardProps = HTMLAttributes<HTMLDivElement> & CardVariantProps
type ButtonElementProps = ButtonHTMLAttributes<HTMLButtonElement> &
CardVariantProps & { as: "button" }
export type CardProps = DivProps | ButtonElementProps
export const Card = forwardRef<HTMLElement, CardProps>(
({ className, variant, as = "div", children, ...props }, ref) => {
const cls = cn(cardStyles({ variant }), className)
if (as === "button") {
return (
<button
className={cls}
ref={ref as React.Ref<HTMLButtonElement>}
type="button"
{...(props as ButtonHTMLAttributes<HTMLButtonElement>)}
>
{children}
</button>
)
}
return (
<div
className={cls}
ref={ref as React.Ref<HTMLDivElement>}
{...(props as HTMLAttributes<HTMLDivElement>)}
>
{children}
</div>
)
},
export const Card = forwardRef<HTMLDivElement, CardProps>(
({ className, variant, ...props }, ref) => (
<div
className={cn(cardStyles({ variant }), className)}
ref={ref}
{...props}
/>
),
)
Card.displayName = "Card"

View file

@ -1,6 +1,5 @@
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"
import { useContext, useMemo } from "react"
import type { ViewMessage } from "../../shared/types"
import { viewMessageSchema, type ViewMessage } from "../../shared/types"
import {
handoffToModel as performModelHandoff,
type ModelHandoffRequest,
@ -32,18 +31,18 @@ export function useApp() {
return useMemo(() => {
return {
/** Call an MCP server tool and await the result. */
async callTool<T = ViewMessage>(
async callTool(
name: string,
args: Record<string, unknown>,
): Promise<ToolCallResult<T>> {
): Promise<ToolCallResult<ViewMessage>> {
if (!app) {
return { ok: false, error: "MCP host is not connected" }
}
try {
const result = (await app.callServerTool({
const result = await app.callServerTool({
name,
arguments: args,
})) as CallToolResult & { structuredContent?: unknown }
})
if (result.isError) {
const text =
result.content?.[0]?.type === "text"
@ -51,9 +50,16 @@ export function useApp() {
: "Tool returned an error"
return { ok: false, error: text }
}
const parsed = viewMessageSchema.safeParse(result.structuredContent)
if (!parsed.success) {
return {
ok: false,
error: "Tool returned invalid structured content",
}
}
return {
ok: true,
data: result.structuredContent as T,
data: parsed.data,
}
} catch (err) {
return { ok: false, error: String(err) }

View file

@ -8,36 +8,21 @@ import { useEffect } from "react"
import { useHostContext } from "./useHostContext"
function applyHostDimensions(ctx: McpUiHostContext) {
const dims = (
ctx as McpUiHostContext & {
containerDimensions?: { height?: number; width?: number }
}
).containerDimensions
if (dims?.height) {
document.documentElement.style.setProperty(
"--host-height",
`${dims.height}px`,
)
const dimensions = ctx.containerDimensions
const height =
dimensions && "height" in dimensions ? dimensions.height : undefined
const width =
dimensions && "width" in dimensions ? dimensions.width : undefined
if (height) {
document.documentElement.style.setProperty("--host-height", `${height}px`)
}
if (dims?.width) {
document.documentElement.style.setProperty(
"--host-width",
`${dims.width}px`,
)
if (width) {
document.documentElement.style.setProperty("--host-width", `${width}px`)
}
}
function applySafeArea(ctx: McpUiHostContext) {
const insets = (
ctx as McpUiHostContext & {
safeAreaInsets?: {
top: number
right: number
bottom: number
left: number
}
}
).safeAreaInsets
const insets = ctx.safeAreaInsets
if (insets) {
const { top, right, bottom, left } = insets
document.body.style.padding = `${top}px ${right}px ${bottom}px ${left}px`

View file

@ -7,6 +7,12 @@ export interface ChatGptHostApi {
widgetState?: unknown
}
declare global {
interface Window {
openai?: ChatGptHostApi
}
}
/**
* Optional ChatGPT host extensions. Shared MCP Apps methods remain the
* cross-host fallback; this bridge is used where ChatGPT provides a stronger
@ -14,5 +20,5 @@ export interface ChatGptHostApi {
*/
export function getChatGptHostApi(): ChatGptHostApi | undefined {
if (typeof window === "undefined") return undefined
return (window as Window & { openai?: ChatGptHostApi }).openai
return window.openai
}

View file

@ -5,7 +5,11 @@ export function readFileAsBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
const result = reader.result as string
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)
}

View file

@ -1,4 +1,8 @@
import type { ViewMessage, ViewName } from "../../shared/types"
import {
viewMessageSchema,
type ViewMessage,
type ViewName,
} from "../../shared/types"
import { getChatGptHostApi } from "./openaiHost"
const CHECKPOINT_VERSION = 1
@ -8,15 +12,6 @@ const CHECKPOINTABLE_VIEWS = new Set<ViewName>([
"save-success",
"upload-success",
])
const VIEW_NAMES = new Set<ViewName>([
"picker",
"confirmation",
"save",
"save-success",
"upload",
"upload-success",
"graph",
])
interface CheckpointEnvelope {
version: number
@ -28,9 +23,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
}
function isViewMessage(value: unknown): value is ViewMessage {
if (!isRecord(value) || typeof value.view !== "string") return false
if (!VIEW_NAMES.has(value.view as ViewName)) return false
return value.viewId === undefined || typeof value.viewId === "string"
return viewMessageSchema.safeParse(value).success
}
function checkpointKey(viewId: string): string {

View file

@ -5,7 +5,9 @@ import { ErrorBoundary } from "./ErrorBoundary"
import { McpAppProvider } from "./McpAppProvider"
import "./design/globals.css"
const root = createRoot(document.getElementById("app") as HTMLElement)
const rootElement = document.getElementById("app")
if (!rootElement) throw new Error("Missing app root")
const root = createRoot(rootElement)
root.render(
<StrictMode>
<McpAppProvider>

View file

@ -8,7 +8,9 @@ import "../design/globals.css"
// primitive and view with mock data. Run with `bun run studio`.
document.documentElement.setAttribute("data-theme", "light")
const root = createRoot(document.getElementById("studio") as HTMLElement)
const rootElement = document.getElementById("studio")
if (!rootElement) throw new Error("Missing studio root")
const root = createRoot(rootElement)
root.render(
<StrictMode>
<McpAppPreviewProvider>

View file

@ -4,7 +4,6 @@ import {
type GraphApiMemory,
type GraphThemeColors,
MemoryGraph,
type MemoryRelation,
} from "@supermemory/memory-graph"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import type {
@ -87,8 +86,6 @@ interface Props {
containerTag?: string
}
type DisplayMode = "inline" | "fullscreen" | "pip"
// Map the widget's API shape (DocumentWithMemories) into the package's
// GraphApiDocument shape. Mirrors console-v2's use-graph-api transform so the
// graph renders identically to the console.
@ -110,9 +107,7 @@ function toGraphMemory(mem: DocumentMemoryEntry): GraphApiMemory {
relation: null,
updatesMemoryId: null,
nextVersionId: null,
memoryRelations:
(mem.memoryRelations as Record<string, MemoryRelation> | undefined) ??
null,
memoryRelations: mem.memoryRelations ?? null,
spaceContainerTag: null,
}
}
@ -143,8 +138,8 @@ export function Graph({ documents, totalCount }: Props) {
// back via context, so an optimistic local toggle is the source of truth;
// we only adopt the host's value when it actually CHANGES (host-initiated
// exit, e.g. ESC at the host level). Without this the button is one-way.
const ctxMode = ctx?.displayMode as DisplayMode | undefined
const [mode, setMode] = useState<DisplayMode>(ctxMode ?? "inline")
const ctxMode = ctx?.displayMode
const [mode, setMode] = useState(ctxMode ?? "inline")
const prevCtxMode = useRef(ctxMode)
useEffect(() => {
if (ctxMode !== prevCtxMode.current) {
@ -155,7 +150,7 @@ export function Graph({ documents, totalCount }: Props) {
// Host theme (light/dark). Drives the graph palette reactively so a
// mid-session theme switch re-themes the canvas.
const theme = (ctx?.theme as string | undefined) ?? "light"
const theme = ctx?.theme ?? "light"
const colors = useGraphColors(theme)
const graphColors = useMemo<Partial<GraphThemeColors>>(
() => ({
@ -167,9 +162,7 @@ export function Graph({ documents, totalCount }: Props) {
)
const fullscreenSupported = useMemo(() => {
const modes = (
ctx as { availableDisplayModes?: string[] } | null | undefined
)?.availableDisplayModes
const modes = ctx?.availableDisplayModes
return Array.isArray(modes) ? modes.includes("fullscreen") : true
}, [ctx])

View file

@ -1,10 +1,16 @@
import type { CSSProperties } from "react"
const loaderStyle: CSSProperties & Record<"--super-loader-size", string> = {
"--super-loader-size": "42px",
}
export function Loading() {
return (
<div className="mcp-widget-loading flex items-center justify-center py-(--space-12)">
<output
aria-label="Loading..."
className="super-loader"
style={{ ["--super-loader-size" as string]: "42px" }}
style={loaderStyle}
>
<svg
aria-hidden="true"

View file

@ -49,7 +49,7 @@ export function Picker({
const handleSelect = async (containerTag: string) => {
log("info", `[picker] select: ${containerTag}`)
setPending(containerTag)
const result = await callTool<ViewMessage>("set-active-tag", {
const result = await callTool("set-active-tag", {
containerTag,
viewId,
})

View file

@ -61,7 +61,7 @@ export function Save({
if (!canSave || !selectedTag) return
log("info", `[save] submit (${trimmed.length} chars → ${selectedTag})`)
setSaving(true)
const result = await callTool<ViewMessage>("save-memory", {
const result = await callTool("save-memory", {
content: trimmed,
containerTag: selectedTag,
viewId,

View file

@ -29,7 +29,8 @@ function formatFileSize(bytes: number): string {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
const ACCEPT = ".txt,.pdf,.png,.jpg,.jpeg,.mp4"
const ACCEPT =
".txt,.md,.pdf,.doc,.docx,.csv,.png,.jpg,.jpeg,.gif,.webp,.mp3,.wav,.m4a,.mp4,.webm"
export function Upload({
activeTag,
@ -64,7 +65,7 @@ export function Upload({
setUploading(true)
try {
const fileData = await readFileAsBase64(file)
const result = await callTool<ViewMessage>("upload-file-submit", {
const result = await callTool("upload-file-submit", {
fileData,
fileName: file.name,
mimeType: file.type,
@ -114,7 +115,7 @@ export function Upload({
return (
<div className="flex flex-col">
<PageHeader
description="Send a file (text, PDF, image, video) into a space."
description="Send a document, image, audio, or video file into a space."
title="Upload File"
/>
<div className="px-(--page-header-px) pb-(--space-6)">
@ -144,7 +145,7 @@ export function Upload({
) : (
<FileUpload
accept={ACCEPT}
description="Supports TXT, PDF, PNG, JPG, MP4"
description="Supports documents, images, audio, and video"
onFile={setFile}
/>
)}