diff --git a/apps/mcp/e2e/graph.test.ts b/apps/mcp/e2e/graph.test.ts index 249886f3..b8811eb4 100644 --- a/apps/mcp/e2e/graph.test.ts +++ b/apps/mcp/e2e/graph.test.ts @@ -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 () => { diff --git a/apps/mcp/src/server/auth/index.ts b/apps/mcp/src/server/auth/index.ts index 0d7a5ed4..a0996933 100644 --- a/apps/mcp/src/server/auth/index.ts +++ b/apps/mcp/src/server/auth/index.ts @@ -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( diff --git a/apps/mcp/src/server/client/index.ts b/apps/mcp/src/server/client/index.ts index e23fe5cc..d3c7bc54 100644 --- a/apps/mcp/src/server/client/index.ts +++ b/apps/mcp/src/server/client/index.ts @@ -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 -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 + +const memoryEntriesResponseSchema = z.object({ + memoryEntries: z.array(memoryEntrySchema), + pagination: paginationSchema, +}) + +export type MemoryEntriesResponse = z.infer 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).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: diff --git a/apps/mcp/src/server/container-tag.ts b/apps/mcp/src/server/container-tag.ts index decb4586..5657794a 100644 --- a/apps/mcp/src/server/container-tag.ts +++ b/apps/mcp/src/server/container-tag.ts @@ -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.", ) diff --git a/apps/mcp/src/server/index.ts b/apps/mcp/src/server/index.ts index 8bc99a62..dafdc9bc 100644 --- a/apps/mcp/src/server/index.ts +++ b/apps/mcp/src/server/index.ts @@ -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()) diff --git a/apps/mcp/src/server/resources/widget.ts b/apps/mcp/src/server/resources/widget.ts index 44d67a0d..2c8ba892 100644 --- a/apps/mcp/src/server/resources/widget.ts +++ b/apps/mcp/src/server/resources/widget.ts @@ -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, }, ], }), diff --git a/apps/mcp/src/server/server.ts b/apps/mcp/src/server/server.ts index 86eb3d54..f0637e7e 100644 --- a/apps/mcp/src/server/server.ts +++ b/apps/mcp/src/server/server.ts @@ -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 - | 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)) diff --git a/apps/mcp/src/server/space-presentation.ts b/apps/mcp/src/server/space-presentation.ts index 630a5f3a..2038f6f6 100644 --- a/apps/mcp/src/server/space-presentation.ts +++ b/apps/mcp/src/server/space-presentation.ts @@ -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)}` diff --git a/apps/mcp/src/server/tools/annotations.ts b/apps/mcp/src/server/tools/annotations.ts index fe77f2e1..afc034ec 100644 --- a/apps/mcp/src/server/tools/annotations.ts +++ b/apps/mcp/src/server/tools/annotations.ts @@ -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 +} diff --git a/apps/mcp/src/server/tools/fetch-graph-data.ts b/apps/mcp/src/server/tools/fetch-graph-data.ts index 4ab2d726..4ecd35c1 100644 --- a/apps/mcp/src/server/tools/fetch-graph-data.ts +++ b/apps/mcp/src/server/tools/fetch-graph-data.ts @@ -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"]), }, diff --git a/apps/mcp/src/server/tools/guided-save.ts b/apps/mcp/src/server/tools/guided-save.ts index 8b17b226..20750379 100644 --- a/apps/mcp/src/server/tools/guided-save.ts +++ b/apps/mcp/src/server/tools/guided-save.ts @@ -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, }, diff --git a/apps/mcp/src/server/tools/list-container-tags.ts b/apps/mcp/src/server/tools/list-container-tags.ts index d8d871c0..955779c2 100644 --- a/apps/mcp/src/server/tools/list-container-tags.ts +++ b/apps/mcp/src/server/tools/list-container-tags.ts @@ -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) diff --git a/apps/mcp/src/server/tools/memory-graph.ts b/apps/mcp/src/server/tools/memory-graph.ts index 77a2aa14..69b71a27 100644 --- a/apps/mcp/src/server/tools/memory-graph.ts +++ b/apps/mcp/src/server/tools/memory-graph.ts @@ -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) diff --git a/apps/mcp/src/server/tools/save-memory.ts b/apps/mcp/src/server/tools/save-memory.ts index 72fb8c0b..945da5ad 100644 --- a/apps/mcp/src/server/tools/save-memory.ts +++ b/apps/mcp/src/server/tools/save-memory.ts @@ -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) => { diff --git a/apps/mcp/src/server/tools/select-space.ts b/apps/mcp/src/server/tools/select-space.ts index 827f8808..1a874473 100644 --- a/apps/mcp/src/server/tools/select-space.ts +++ b/apps/mcp/src/server/tools/select-space.ts @@ -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, }, diff --git a/apps/mcp/src/server/tools/set-active-tag.ts b/apps/mcp/src/server/tools/set-active-tag.ts index 7797b460..4db411e2 100644 --- a/apps/mcp/src/server/tools/set-active-tag.ts +++ b/apps/mcp/src/server/tools/set-active-tag.ts @@ -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, }, diff --git a/apps/mcp/src/server/tools/upload-file-submit.ts b/apps/mcp/src/server/tools/upload-file-submit.ts index 33a38b8d..d5347313 100644 --- a/apps/mcp/src/server/tools/upload-file-submit.ts +++ b/apps/mcp/src/server/tools/upload-file-submit.ts @@ -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, diff --git a/apps/mcp/src/server/tools/upload-file.ts b/apps/mcp/src/server/tools/upload-file.ts index 6b905cd3..2ada7d69 100644 --- a/apps/mcp/src/server/tools/upload-file.ts +++ b/apps/mcp/src/server/tools/upload-file.ts @@ -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, }, diff --git a/apps/mcp/src/server/tools/who-am-i.ts b/apps/mcp/src/server/tools/who-am-i.ts index c168d57c..0b71a30e 100644 --- a/apps/mcp/src/server/tools/who-am-i.ts +++ b/apps/mcp/src/server/tools/who-am-i.ts @@ -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, }, diff --git a/apps/mcp/src/shared/types.ts b/apps/mcp/src/shared/types.ts index 31329667..f17d52eb 100644 --- a/apps/mcp/src/shared/types.ts +++ b/apps/mcp/src/shared/types.ts @@ -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 -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 -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 - 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 -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 + +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 + +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 + +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 // 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 + +export const graphResultMetaSchema = z.looseObject({ + graphData: z.object({ + documents: z.array(documentWithMemoriesSchema), + }), +}) + +export type GraphResultMeta = z.infer export type ViewName = ViewMessage["view"] diff --git a/apps/mcp/src/widget/App.tsx b/apps/mcp/src/widget/App.tsx index 62749b59..1d1774ac 100644 --- a/apps/mcp/src/widget/App.tsx +++ b/apps/mcp/src/widget/App.tsx @@ -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 ( - {renderView(state.message, setView, setError)} + {renderView(state.message, state.resultMeta, setView, setError)} ) } @@ -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 + } return ( ) case "confirmation": diff --git a/apps/mcp/src/widget/McpAppProvider.tsx b/apps/mcp/src/widget/McpAppProvider.tsx index 0410df11..f17299bd 100644 --- a/apps/mcp/src/widget/McpAppProvider.tsx +++ b/apps/mcp/src/widget/McpAppProvider.tsx @@ -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) diff --git a/apps/mcp/src/widget/design/ui/Card.tsx b/apps/mcp/src/widget/design/ui/Card.tsx index 20d578d7..ba6373ba 100644 --- a/apps/mcp/src/widget/design/ui/Card.tsx +++ b/apps/mcp/src/widget/design/ui/Card.tsx @@ -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 -type DivProps = HTMLAttributes & - CardVariantProps & { as?: "div" } +export type CardProps = HTMLAttributes & CardVariantProps -type ButtonElementProps = ButtonHTMLAttributes & - CardVariantProps & { as: "button" } - -export type CardProps = DivProps | ButtonElementProps - -export const Card = forwardRef( - ({ className, variant, as = "div", children, ...props }, ref) => { - const cls = cn(cardStyles({ variant }), className) - if (as === "button") { - return ( - - ) - } - return ( -
} - {...(props as HTMLAttributes)} - > - {children} -
- ) - }, +export const Card = forwardRef( + ({ className, variant, ...props }, ref) => ( +
+ ), ) Card.displayName = "Card" diff --git a/apps/mcp/src/widget/hooks/useApp.ts b/apps/mcp/src/widget/hooks/useApp.ts index 01f67e66..482a1fc8 100644 --- a/apps/mcp/src/widget/hooks/useApp.ts +++ b/apps/mcp/src/widget/hooks/useApp.ts @@ -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( + async callTool( name: string, args: Record, - ): Promise> { + ): Promise> { 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) } diff --git a/apps/mcp/src/widget/hooks/useApplyHostTheme.ts b/apps/mcp/src/widget/hooks/useApplyHostTheme.ts index 7eeed0bd..6ab1d4c1 100644 --- a/apps/mcp/src/widget/hooks/useApplyHostTheme.ts +++ b/apps/mcp/src/widget/hooks/useApplyHostTheme.ts @@ -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` diff --git a/apps/mcp/src/widget/lib/openaiHost.ts b/apps/mcp/src/widget/lib/openaiHost.ts index 678f04b5..a2e9bbad 100644 --- a/apps/mcp/src/widget/lib/openaiHost.ts +++ b/apps/mcp/src/widget/lib/openaiHost.ts @@ -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 } diff --git a/apps/mcp/src/widget/lib/readFileAsBase64.ts b/apps/mcp/src/widget/lib/readFileAsBase64.ts index ece33e84..3561e57c 100644 --- a/apps/mcp/src/widget/lib/readFileAsBase64.ts +++ b/apps/mcp/src/widget/lib/readFileAsBase64.ts @@ -5,7 +5,11 @@ export function readFileAsBase64(file: File): Promise { 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) } diff --git a/apps/mcp/src/widget/lib/viewCheckpoint.ts b/apps/mcp/src/widget/lib/viewCheckpoint.ts index 2d150620..0cfc7e24 100644 --- a/apps/mcp/src/widget/lib/viewCheckpoint.ts +++ b/apps/mcp/src/widget/lib/viewCheckpoint.ts @@ -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([ "save-success", "upload-success", ]) -const VIEW_NAMES = new Set([ - "picker", - "confirmation", - "save", - "save-success", - "upload", - "upload-success", - "graph", -]) interface CheckpointEnvelope { version: number @@ -28,9 +23,7 @@ function isRecord(value: unknown): value is Record { } 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 { diff --git a/apps/mcp/src/widget/main.tsx b/apps/mcp/src/widget/main.tsx index 26b0718f..1a244e83 100644 --- a/apps/mcp/src/widget/main.tsx +++ b/apps/mcp/src/widget/main.tsx @@ -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( diff --git a/apps/mcp/src/widget/studio/main.tsx b/apps/mcp/src/widget/studio/main.tsx index b5cfdac6..53918142 100644 --- a/apps/mcp/src/widget/studio/main.tsx +++ b/apps/mcp/src/widget/studio/main.tsx @@ -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( diff --git a/apps/mcp/src/widget/views/Graph.tsx b/apps/mcp/src/widget/views/Graph.tsx index 33925ee4..d2933b4a 100644 --- a/apps/mcp/src/widget/views/Graph.tsx +++ b/apps/mcp/src/widget/views/Graph.tsx @@ -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 | 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(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>( () => ({ @@ -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]) diff --git a/apps/mcp/src/widget/views/Loading.tsx b/apps/mcp/src/widget/views/Loading.tsx index 513498ab..03635b93 100644 --- a/apps/mcp/src/widget/views/Loading.tsx +++ b/apps/mcp/src/widget/views/Loading.tsx @@ -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 (
@@ -144,7 +145,7 @@ export function Upload({ ) : ( )}