diff --git a/apps/mcp/README.md b/apps/mcp/README.md index a8325482..bcceafbe 100644 --- a/apps/mcp/README.md +++ b/apps/mcp/README.md @@ -82,7 +82,7 @@ These tools are available to the embedded MCP App and hidden from the model. | --- | --- | --- | | Resource | `supermemory://profile` | Profile facts in the effective space | | Resource | `supermemory://spaces` | Visible spaces | -| Resource | `ui://supermemory/app-v4.html` | Embedded MCP App bundle | +| Resource | `ui://supermemory/app-.html` | Embedded MCP App bundle | | Prompt | `context` | Profile and recent context for an optional space | The App resource and tool metadata include both current nested `ui` metadata and diff --git a/apps/mcp/e2e/graph.test.ts b/apps/mcp/e2e/graph.test.ts index 5a231e5f..74d76d92 100644 --- a/apps/mcp/e2e/graph.test.ts +++ b/apps/mcp/e2e/graph.test.ts @@ -1,5 +1,5 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest" -import { graphViewSchema } from "../src/shared/types" +import { graphResultMetaSchema, graphViewSchema } from "../src/shared/types" import { OAUTH_CREDENTIALS_AVAILABLE, callTool, @@ -27,6 +27,10 @@ describeWithAuth("MCP — graph, resources & prompts", () => { expect(result.success).toBe(true) if (!result.success) throw result.error expect(result.data.view).toBe("graph") + expect(result.data.rendered).toBe(true) + expect(result.data.documentCount).toBe(result.data.documents.length) + const resultMeta = graphResultMetaSchema.safeParse(res._meta) + expect(resultMeta.success).toBe(true) }) it("fetch-graph-data returns paginated documents", async () => { diff --git a/apps/mcp/e2e/helpers.ts b/apps/mcp/e2e/helpers.ts index e9abc436..2d8a379d 100644 --- a/apps/mcp/e2e/helpers.ts +++ b/apps/mcp/e2e/helpers.ts @@ -168,6 +168,7 @@ export async function exchangeRefreshToken( export type CallResult = { content?: Array<{ type: string; text?: string }> structuredContent?: unknown + _meta?: unknown isError?: boolean } diff --git a/apps/mcp/package.json b/apps/mcp/package.json index 2be89db5..d33880d5 100644 --- a/apps/mcp/package.json +++ b/apps/mcp/package.json @@ -7,15 +7,15 @@ "script": "dev:app" }, "scripts": { - "build:widget": "vite build", - "build": "vite build", + "build:widget": "bun run scripts/build-widget.ts", + "build": "bun run build:widget", "dev": "portless", - "dev:app": "vite build && wrangler dev --port ${PORT:-8788}", + "dev:app": "wrangler dev --port ${PORT:-8788}", "dev:widget": "vite --config vite.config.dev.ts", "studio": "vite --config vite.config.dev.ts --open /studio.html", - "deploy": "vite build && wrangler deploy --minify", - "check-types": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.widget.json", - "test:unit": "vitest run src", + "deploy": "wrangler deploy --minify", + "check-types": "bun run build:widget && tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.widget.json", + "test:unit": "bun run build:widget && vitest run src", "test:e2e": "vitest run e2e", "cf-typegen": "wrangler types --env-interface CloudflareBindings" }, diff --git a/apps/mcp/scripts/build-widget.ts b/apps/mcp/scripts/build-widget.ts new file mode 100644 index 00000000..c5b8e0b3 --- /dev/null +++ b/apps/mcp/scripts/build-widget.ts @@ -0,0 +1,29 @@ +import { createHash } from "node:crypto" +import { mkdir, readFile, writeFile } from "node:fs/promises" +import { fileURLToPath } from "node:url" +import { build } from "vite" +import { WIDGET_RESOURCE_META } from "../src/server/widget-resource-metadata" + +const appRoot = fileURLToPath(new URL("../", import.meta.url)) +const htmlUrl = new URL("../dist/src/widget/index.html", import.meta.url) +const manifestUrl = new URL("../dist/widget-manifest.json", import.meta.url) +const artifactsUrl = new URL("../dist/widgets/", import.meta.url) + +await build({ + configFile: fileURLToPath(new URL("../vite.config.ts", import.meta.url)), + root: appRoot, +}) + +const html = await readFile(htmlUrl, "utf8") +const sha256 = createHash("sha256") + .update(JSON.stringify({ html, meta: WIDGET_RESOURCE_META })) + .digest("hex") +const resourceUri = `ui://supermemory/app-${sha256}.html` +const manifest = { resourceUri, sha256 } + +await mkdir(artifactsUrl, { recursive: true }) +await writeFile(manifestUrl, `${JSON.stringify(manifest, null, 2)}\n`) +await writeFile( + new URL(`${sha256}.json`, artifactsUrl), + `${JSON.stringify({ ...manifest, html, meta: WIDGET_RESOURCE_META })}\n`, +) diff --git a/apps/mcp/src/server/app-metadata.test.ts b/apps/mcp/src/server/app-metadata.test.ts index 2ffe5602..74ab25dc 100644 --- a/apps/mcp/src/server/app-metadata.test.ts +++ b/apps/mcp/src/server/app-metadata.test.ts @@ -1,9 +1,15 @@ import { describe, expect, it } from "vitest" -import { SUPERMEMORY_RESOURCE_URI } from "../shared/types" -import { appResultMeta, appToolMeta } from "./app-metadata" +import { + appResultMeta, + appToolMeta, + SUPERMEMORY_RESOURCE_URI, +} from "./app-metadata" describe("MCP Apps metadata compatibility", () => { it("advertises both current and legacy resource URI metadata", () => { + expect(SUPERMEMORY_RESOURCE_URI).toMatch( + /^ui:\/\/supermemory\/app-[a-f0-9]{64}\.html$/, + ) expect(appToolMeta()).toEqual({ ui: { resourceUri: SUPERMEMORY_RESOURCE_URI }, "ui/resourceUri": SUPERMEMORY_RESOURCE_URI, diff --git a/apps/mcp/src/server/app-metadata.ts b/apps/mcp/src/server/app-metadata.ts index 676aaebc..ca3b29e3 100644 --- a/apps/mcp/src/server/app-metadata.ts +++ b/apps/mcp/src/server/app-metadata.ts @@ -1,6 +1,7 @@ -import { SUPERMEMORY_RESOURCE_URI } from "../shared/types" +import widgetManifest from "../../dist/widget-manifest.json" export const APP_RESOURCE_MIME_TYPE = "text/html;profile=mcp-app" +export const SUPERMEMORY_RESOURCE_URI = widgetManifest.resourceUri type AppVisibility = "model" | "app" diff --git a/apps/mcp/src/server/format.ts b/apps/mcp/src/server/format.ts index 738d9275..c9bb3f11 100644 --- a/apps/mcp/src/server/format.ts +++ b/apps/mcp/src/server/format.ts @@ -128,15 +128,25 @@ export function formatMemoryEntriesList( return parts.join("\n") } -function documentContent(document: DocumentDetails): string | null { +export function getDocumentContent(document: DocumentDetails): { + content: string | null + truncated: boolean +} { + let content: string | null = null if (typeof document.raw === "string" && document.raw.trim()) { - return document.raw + content = document.raw + } else if (document.raw !== null && document.raw !== undefined) { + content = JSON.stringify(document.raw, null, 2) + } else if (document.content?.trim()) { + content = document.content } - if (document.raw !== null && document.raw !== undefined) { - return JSON.stringify(document.raw, null, 2) + + if (!content) return { content: null, truncated: false } + const truncated = content.length > MAX_DOCUMENT_CONTENT_CHARS + return { + content: truncated ? content.slice(0, MAX_DOCUMENT_CONTENT_CHARS) : content, + truncated, } - if (document.content?.trim()) return document.content - return null } export function formatDocument(document: DocumentDetails): string { @@ -155,13 +165,13 @@ export function formatDocument(document: DocumentDetails): string { parts.push("", "## Summary", compactText(document.summary, 4_000)) } - const content = documentContent(document) + const { content, truncated } = getDocumentContent(document) if (content) { - const truncated = - content.length > MAX_DOCUMENT_CONTENT_CHARS - ? `${content.slice(0, MAX_DOCUMENT_CONTENT_CHARS)}\n\n[Document content truncated]` - : content - parts.push("", "## Content", truncated) + parts.push( + "", + "## Content", + truncated ? `${content}\n\n[Document content truncated]` : content, + ) } else { parts.push("", "No document content is available.") } diff --git a/apps/mcp/src/server/resources/widget.ts b/apps/mcp/src/server/resources/widget.ts index 69f2b646..ed817318 100644 --- a/apps/mcp/src/server/resources/widget.ts +++ b/apps/mcp/src/server/resources/widget.ts @@ -1,28 +1,13 @@ import type { McpServer } from "@modelcontextprotocol/server" import supermemoryAppHtml from "../../../dist/src/widget/index.html" -import { SUPERMEMORY_RESOURCE_URI } from "../../shared/types" -import { APP_RESOURCE_MIME_TYPE } from "../app-metadata" - -const CSP_DOMAINS = [ - "https://fonts.googleapis.com", - "https://fonts.gstatic.com", -] - -const WIDGET_DESCRIPTION = - "Interactive Supermemory view for memory graphs, space selection, guided saves, file uploads, and confirmations." - -const RESOURCE_UI_META = { - prefersBorder: true, - csp: { - resourceDomains: [...CSP_DOMAINS], - connectDomains: [...CSP_DOMAINS], - }, -} - -const RESOURCE_META = { - ui: RESOURCE_UI_META, - "openai/widgetDescription": WIDGET_DESCRIPTION, -} +import { + APP_RESOURCE_MIME_TYPE, + SUPERMEMORY_RESOURCE_URI, +} from "../app-metadata" +import { + WIDGET_DESCRIPTION, + WIDGET_RESOURCE_META, +} from "../widget-resource-metadata" export function registerWidgetResource(server: McpServer) { server.registerResource( @@ -34,7 +19,7 @@ export function registerWidgetResource(server: McpServer) { { mimeType: APP_RESOURCE_MIME_TYPE, description: WIDGET_DESCRIPTION, - _meta: RESOURCE_META, + _meta: WIDGET_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 @@ -45,7 +30,7 @@ export function registerWidgetResource(server: McpServer) { uri: SUPERMEMORY_RESOURCE_URI, mimeType: APP_RESOURCE_MIME_TYPE, text: supermemoryAppHtml, - _meta: RESOURCE_META, + _meta: WIDGET_RESOURCE_META, }, ], }), diff --git a/apps/mcp/src/server/server.ts b/apps/mcp/src/server/server.ts index f0637e7e..cfeb7a64 100644 --- a/apps/mcp/src/server/server.ts +++ b/apps/mcp/src/server/server.ts @@ -9,7 +9,7 @@ import { type WaitUntil, } from "./analytics" import { fetchSession } from "./auth" -import { SupermemoryClient } from "./client" +import { DEFAULT_PROJECT_ID, SupermemoryClient } from "./client" import { registerContextPrompt } from "./prompts/context" import { registerContainerTagsResource } from "./resources/container-tags" import { registerProfileResource } from "./resources/profile" @@ -64,8 +64,10 @@ export function createSupermemoryServer( const getActiveContainerTag = () => spaceState.getActiveContainerTag() const setActiveContainerTag = (containerTag: string) => spaceState.setActiveContainerTag(containerTag) - const resolveContainerTag = (explicit?: string) => + const resolveSelectedContainerTag = (explicit?: string) => resolveSpaceContainerTag(explicit, getActiveContainerTag) + const resolveContainerTag = async (explicit?: string) => + (await resolveSelectedContainerTag(explicit)) ?? DEFAULT_PROJECT_ID const analytics = createPosthogAnalytics(env, actor, waitUntil) const toolServer = createTrackedToolServer( server, @@ -85,10 +87,14 @@ export function createSupermemoryServer( errorResult, }) - registerProfileResource(server, getClient, resolveContainerTag) - registerContainerTagsResource(server, () => getClient(), resolveContainerTag) + registerProfileResource(server, getClient, resolveSelectedContainerTag) + registerContainerTagsResource( + server, + () => getClient(), + resolveSelectedContainerTag, + ) registerWidgetResource(server) - registerContextPrompt(server, getClient, resolveContainerTag) + registerContextPrompt(server, getClient, resolveSelectedContainerTag) return server } diff --git a/apps/mcp/src/server/tools/add-memory.ts b/apps/mcp/src/server/tools/add-memory.ts index 295b3b88..c708deb5 100644 --- a/apps/mcp/src/server/tools/add-memory.ts +++ b/apps/mcp/src/server/tools/add-memory.ts @@ -1,7 +1,8 @@ import { z } from "zod" import { optionalContainerTagSchema } from "../container-tag" import { MEMORY_TOOL_ANNOTATIONS } from "./annotations" -import type { ToolDeps } from "./types" +import { addMemoryOutputSchema, type AddMemoryOutput } from "./output-schemas" +import { textContent, type ToolDeps } from "./types" export function register(deps: ToolDeps) { const inputSchema = z.object({ @@ -19,6 +20,7 @@ export function register(deps: ToolDeps) { description: "Add (save) or forget a memory in the user's ACTIVE space. Defaults to 'save'. The target space is the one the user selected via select-space; pass containerTag only to override it. Use 'forget' when information is outdated or the user asks to remove it.", inputSchema, + outputSchema: addMemoryOutputSchema, annotations: MEMORY_TOOL_ANNOTATIONS, }, async (args) => { @@ -28,19 +30,31 @@ export function register(deps: ToolDeps) { if (args.action === "forget") { const result = await client.forgetMemory(args.content) + const structuredContent: AddMemoryOutput = { + action: "forget", + success: result.success, + containerTag: result.containerTag, + message: result.message, + } return { - content: [{ type: "text" as const, text: result.message }], + content: [textContent(result.message)], + structuredContent, } } const result = await client.createMemory(args.content) + const message = `Memory saved (ID: ${result.id}, space: ${result.containerTag})` + const structuredContent: AddMemoryOutput = { + action: "save", + success: true, + containerTag: result.containerTag, + message, + id: result.id, + status: result.status, + } return { - content: [ - { - type: "text" as const, - text: `Memory saved (ID: ${result.id}, space: ${result.containerTag})`, - }, - ], + content: [textContent(message)], + structuredContent, } } catch (error) { return deps.errorResult(error) diff --git a/apps/mcp/src/server/tools/fetch-graph-data.ts b/apps/mcp/src/server/tools/fetch-graph-data.ts index 7d115356..9c1345a8 100644 --- a/apps/mcp/src/server/tools/fetch-graph-data.ts +++ b/apps/mcp/src/server/tools/fetch-graph-data.ts @@ -3,7 +3,7 @@ import { documentsApiResponseSchema } from "../../shared/types" import { appToolMeta } from "../app-metadata" import { optionalContainerTagSchema } from "../container-tag" import { READ_ONLY_TOOL_ANNOTATIONS } from "./annotations" -import type { ToolDeps } from "./types" +import { textContent, type ToolDeps } from "./types" export function register(deps: ToolDeps) { deps.server.registerTool( @@ -23,19 +23,17 @@ export function register(deps: ToolDeps) { try { const effectiveTag = await deps.resolveContainerTag(args.containerTag) const client = deps.getClient(effectiveTag) - const containerTags = effectiveTag ? [effectiveTag] : undefined const data = await client.getDocuments( - containerTags, + [effectiveTag], args.page, args.limit, ) return { content: [ - { - type: "text" as const, - text: `Loaded ${data.documents.length} documents for the memory graph.`, - }, + textContent( + `Loaded ${data.documents.length} documents for the memory graph.`, + ), ], structuredContent: data, } diff --git a/apps/mcp/src/server/tools/get-document.ts b/apps/mcp/src/server/tools/get-document.ts index c1d30a6e..01535c73 100644 --- a/apps/mcp/src/server/tools/get-document.ts +++ b/apps/mcp/src/server/tools/get-document.ts @@ -1,7 +1,11 @@ import { z } from "zod" -import { formatDocument } from "../format" +import { formatDocument, getDocumentContent } from "../format" import { READ_ONLY_TOOL_ANNOTATIONS } from "./annotations" -import type { ToolDeps } from "./types" +import { + getDocumentOutputSchema, + type GetDocumentOutput, +} from "./output-schemas" +import { textContent, type ToolDeps } from "./types" export function register(deps: ToolDeps) { const inputSchema = z.object({ @@ -19,15 +23,32 @@ export function register(deps: ToolDeps) { description: "Read one stored document by ID, including its summary and available content. Use listDocuments in the intended space to discover document IDs.", inputSchema, + outputSchema: getDocumentOutputSchema, annotations: READ_ONLY_TOOL_ANNOTATIONS, }, async (args) => { try { const client = deps.getClient() const document = await client.getDocument(args.documentId) + const { content, truncated } = getDocumentContent(document) + const structuredContent: GetDocumentOutput = { + document: { + id: document.id, + title: document.title, + type: document.type, + status: document.status, + createdAt: document.createdAt, + updatedAt: document.updatedAt, + url: document.url ?? null, + summary: document.summary, + content, + contentTruncated: truncated, + }, + } return { - content: [{ type: "text" as const, text: formatDocument(document) }], + content: [textContent(formatDocument(document))], + structuredContent, } } catch (error) { return deps.errorResult(error) diff --git a/apps/mcp/src/server/tools/guided-save.ts b/apps/mcp/src/server/tools/guided-save.ts index 20750379..aac1e7ea 100644 --- a/apps/mcp/src/server/tools/guided-save.ts +++ b/apps/mcp/src/server/tools/guided-save.ts @@ -3,7 +3,7 @@ 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" -import type { ToolDeps } from "./types" +import { textContent, type ToolDeps } from "./types" export function register(deps: ToolDeps) { deps.server.registerTool( @@ -44,9 +44,7 @@ export function register(deps: ToolDeps) { } return { - content: [ - { type: "text" as const, text: "Opening memory save form..." }, - ], + content: [textContent("Opening memory save form...")], structuredContent: sc, _meta: appResultMeta(viewId), } diff --git a/apps/mcp/src/server/tools/list-container-tags.ts b/apps/mcp/src/server/tools/list-container-tags.ts index 955779c2..8f9aeb27 100644 --- a/apps/mcp/src/server/tools/list-container-tags.ts +++ b/apps/mcp/src/server/tools/list-container-tags.ts @@ -1,7 +1,7 @@ import { z } from "zod" import { listSpacesOutputSchema } from "../../shared/types" import { READ_ONLY_TOOL_ANNOTATIONS } from "./annotations" -import type { ToolDeps } from "./types" +import { textContent, type ToolDeps } from "./types" export function register(deps: ToolDeps) { deps.server.registerTool( @@ -29,12 +29,7 @@ export function register(deps: ToolDeps) { if (tags.length === 0) { return { - content: [ - { - type: "text" as const, - text: "No spaces found.", - }, - ], + content: [textContent("No spaces found.")], structuredContent: { spaces, count: 0 }, } } @@ -46,12 +41,7 @@ export function register(deps: ToolDeps) { }) return { - content: [ - { - type: "text" as const, - text: `Available spaces:\n${lines.join("\n")}`, - }, - ], + content: [textContent(`Available spaces:\n${lines.join("\n")}`)], structuredContent: { spaces, count: spaces.length }, } } catch (error) { diff --git a/apps/mcp/src/server/tools/list-documents.ts b/apps/mcp/src/server/tools/list-documents.ts index 6d436ca5..20f8b59b 100644 --- a/apps/mcp/src/server/tools/list-documents.ts +++ b/apps/mcp/src/server/tools/list-documents.ts @@ -2,7 +2,11 @@ import { z } from "zod" import { optionalContainerTagSchema } from "../container-tag" import { formatDocumentsList } from "../format" import { READ_ONLY_TOOL_ANNOTATIONS } from "./annotations" -import type { ToolDeps } from "./types" +import { + listDocumentsOutputSchema, + type ListDocumentsOutput, +} from "./output-schemas" +import { textContent, type ToolDeps } from "./types" export function register(deps: ToolDeps) { const inputSchema = z.object({ @@ -31,6 +35,7 @@ export function register(deps: ToolDeps) { description: "List documents in one space with their IDs, titles, types, processing status, dates, and summaries. This does not return full document content; use getDocument with an ID from this result to read one document. When the user names a space, resolve it with listSpaces and pass containerTag; otherwise use the active space.", inputSchema, + outputSchema: listDocumentsOutputSchema, annotations: READ_ONLY_TOOL_ANNOTATIONS, }, async (args) => { @@ -41,9 +46,27 @@ export function register(deps: ToolDeps) { args.page ?? 1, args.limit ?? 10, ) + const structuredContent: ListDocumentsOutput = { + documents: data.documents.map((document) => ({ + id: document.id, + title: document.title, + type: document.type, + status: document.status, + createdAt: document.createdAt, + updatedAt: document.updatedAt, + summary: document.summary, + })), + pagination: { + currentPage: data.pagination.currentPage, + limit: data.pagination.limit ?? args.limit ?? 10, + totalItems: data.pagination.totalItems, + totalPages: data.pagination.totalPages, + }, + } return { - content: [{ type: "text" as const, text: formatDocumentsList(data) }], + content: [textContent(formatDocumentsList(data))], + structuredContent, } } catch (error) { return deps.errorResult(error) diff --git a/apps/mcp/src/server/tools/list-memories.ts b/apps/mcp/src/server/tools/list-memories.ts index a74ac31c..37c36b07 100644 --- a/apps/mcp/src/server/tools/list-memories.ts +++ b/apps/mcp/src/server/tools/list-memories.ts @@ -2,7 +2,11 @@ import { z } from "zod" import { optionalContainerTagSchema } from "../container-tag" import { formatMemoryEntriesList } from "../format" import { READ_ONLY_TOOL_ANNOTATIONS } from "./annotations" -import type { ToolDeps } from "./types" +import { + listMemoriesOutputSchema, + type ListMemoriesOutput, +} from "./output-schemas" +import { textContent, type ToolDeps } from "./types" export function register(deps: ToolDeps) { const inputSchema = z.object({ @@ -31,6 +35,7 @@ export function register(deps: ToolDeps) { description: "List the latest extracted memory entries in one space, including stable memory IDs, version information, and source document IDs. This lists memories directly, not documents. When the user names a space, resolve it with listSpaces and pass containerTag; otherwise use the active space. Use search_memory instead for semantic recall.", inputSchema, + outputSchema: listMemoriesOutputSchema, annotations: READ_ONLY_TOOL_ANNOTATIONS, }, async (args) => { @@ -41,11 +46,16 @@ export function register(deps: ToolDeps) { args.page ?? 1, args.limit ?? 10, ) + const structuredContent: ListMemoriesOutput = { + memoryEntries: data.memoryEntries.filter( + (entry) => entry.isForgotten !== true && entry.isLatest !== false, + ), + pagination: data.pagination, + } return { - content: [ - { type: "text" as const, text: formatMemoryEntriesList(data) }, - ], + content: [textContent(formatMemoryEntriesList(data))], + structuredContent, } } 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 70d1949f..5b0177b7 100644 --- a/apps/mcp/src/server/tools/memory-graph.ts +++ b/apps/mcp/src/server/tools/memory-graph.ts @@ -1,9 +1,13 @@ import { z } from "zod" -import { graphViewSchema, type ViewMessage } from "../../shared/types" +import { + graphViewSchema, + type GraphResultMeta, + 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" +import { textContent, type ToolDeps } from "./types" export function register(deps: ToolDeps) { const inputSchema = z.object({ @@ -26,8 +30,7 @@ export function register(deps: ToolDeps) { const viewId = crypto.randomUUID() const effectiveTag = await deps.resolveContainerTag(args.containerTag) const client = deps.getClient(effectiveTag) - const containerTags = effectiveTag ? [effectiveTag] : undefined - const result = await client.getDocuments(containerTags, 1, 200) + const result = await client.getDocuments([effectiveTag], 1, 200) const memoryCount = result.documents.reduce( (sum, document) => sum + document.memoryEntries.length, 0, @@ -35,20 +38,27 @@ export function register(deps: ToolDeps) { const sc: ViewMessage = { view: "graph", viewId, - ...(effectiveTag ? { containerTag: effectiveTag } : {}), + containerTag: effectiveTag, documents: result.documents, totalCount: result.pagination.totalItems, + documentCount: result.documents.length, + memoryCount, + totalDocumentCount: result.pagination.totalItems, + truncated: result.documents.length < result.pagination.totalItems, + rendered: true, + } + const graphMeta: GraphResultMeta = { + graphData: { documents: result.documents }, } 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.`, - }, + textContent( + `Rendered the interactive Memory Graph MCP App: ${result.documents.length} documents, ${memoryCount} memories. Space: ${effectiveTag}. Do not create a duplicate graph or artifact unless the user explicitly requests one.`, + ), ], structuredContent: sc, - _meta: appResultMeta(viewId), + _meta: { ...appResultMeta(viewId), ...graphMeta }, } } catch (error) { return deps.errorResult(error) diff --git a/apps/mcp/src/server/tools/output-schemas.ts b/apps/mcp/src/server/tools/output-schemas.ts new file mode 100644 index 00000000..0156d9d1 --- /dev/null +++ b/apps/mcp/src/server/tools/output-schemas.ts @@ -0,0 +1,155 @@ +import { z } from "zod" +import { + containerTagAccessSchema, + paginationSchema, + sessionScopeSchema, +} from "../../shared/types" + +const documentStatusSchema = z.enum([ + "unknown", + "queued", + "extracting", + "chunking", + "embedding", + "indexing", + "done", + "failed", +]) + +const documentTypeSchema = z.enum([ + "text", + "pdf", + "tweet", + "google_doc", + "google_slide", + "google_sheet", + "image", + "video", + "audio", + "notion_doc", + "webpage", + "onedrive", + "github_markdown", +]) + +const documentSummarySchema = z.object({ + id: z.string(), + title: z.string().nullable(), + type: documentTypeSchema, + status: documentStatusSchema, + createdAt: z.string(), + updatedAt: z.string(), + summary: z.string().nullable(), +}) + +const memoryHistorySchema = z.object({ + 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(), +}) + +const memoryEntryOutputSchema = z.object({ + 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(memoryHistorySchema).optional(), +}) + +export const addMemoryOutputSchema = z.object({ + action: z.enum(["save", "forget"]), + success: z.boolean(), + containerTag: z.string(), + message: z.string(), + id: z.string().optional(), + status: z.string().optional(), +}) + +export type AddMemoryOutput = z.infer + +export const getDocumentOutputSchema = z.object({ + document: z.object({ + id: z.string(), + title: z.string().nullable(), + type: documentTypeSchema, + status: documentStatusSchema, + createdAt: z.string(), + updatedAt: z.string(), + url: z.string().nullable(), + summary: z.string().nullable(), + content: z.string().nullable(), + contentTruncated: z.boolean(), + }), +}) + +export type GetDocumentOutput = z.infer + +export const listDocumentsOutputSchema = z.object({ + documents: z.array(documentSummarySchema), + pagination: paginationSchema, +}) + +export type ListDocumentsOutput = z.infer + +export const listMemoriesOutputSchema = z.object({ + memoryEntries: z.array(memoryEntryOutputSchema), + pagination: paginationSchema, +}) + +export type ListMemoriesOutput = z.infer + +export const searchMemoryOutputSchema = z.object({ + query: z.string(), + containerTag: z.string(), + profile: z + .object({ + static: z.array(z.string()), + dynamic: z.array(z.string()), + }) + .optional(), + results: z.array( + z.object({ + id: z.string(), + text: z.string(), + similarity: z.number(), + title: z.string().optional(), + }), + ), + total: z.number(), + timing: z.number(), +}) + +export type SearchMemoryOutput = z.infer + +export const whoAmIOutputSchema = z.object({ + userId: z.string(), + email: z.string().optional(), + name: z.string().optional(), + role: z.string(), + accessType: z.enum(["full", "restricted"]), + activeSpace: z.string().nullable(), + assignedSpaces: z.array(containerTagAccessSchema).nullable(), + scope: sessionScopeSchema.optional(), + client: z + .object({ + name: z.string(), + version: z.string().optional(), + }) + .optional(), + sessionId: z.string().optional(), +}) + +export type WhoAmIOutput = z.infer diff --git a/apps/mcp/src/server/tools/save-memory.ts b/apps/mcp/src/server/tools/save-memory.ts index 945da5ad..8b53f032 100644 --- a/apps/mcp/src/server/tools/save-memory.ts +++ b/apps/mcp/src/server/tools/save-memory.ts @@ -3,7 +3,7 @@ import { saveSuccessViewSchema, type ViewMessage } from "../../shared/types" import { appResultMeta, appToolMeta } from "../app-metadata" import { containerTagSchema } from "../container-tag" import { ADDITIVE_MEMORY_TOOL_ANNOTATIONS } from "./annotations" -import type { ToolDeps } from "./types" +import { textContent, type ToolDeps } from "./types" export function register(deps: ToolDeps) { deps.server.registerTool( @@ -31,9 +31,7 @@ export function register(deps: ToolDeps) { containerTag: args.containerTag, } return { - content: [ - { type: "text" as const, text: `Memory saved: ${result.id}` }, - ], + content: [textContent(`Memory saved: ${result.id}`)], structuredContent: sc, _meta: appResultMeta(viewId), } diff --git a/apps/mcp/src/server/tools/search-memory.ts b/apps/mcp/src/server/tools/search-memory.ts index 37c05719..f4bfbc63 100644 --- a/apps/mcp/src/server/tools/search-memory.ts +++ b/apps/mcp/src/server/tools/search-memory.ts @@ -2,7 +2,11 @@ import { z } from "zod" import { getMemoryText } from "../client" import { optionalContainerTagSchema } from "../container-tag" import { READ_ONLY_TOOL_ANNOTATIONS } from "./annotations" -import type { ToolDeps } from "./types" +import { + searchMemoryOutputSchema, + type SearchMemoryOutput, +} from "./output-schemas" +import { textContent, type ToolDeps } from "./types" export function register(deps: ToolDeps) { const inputSchema = z.object({ @@ -20,6 +24,7 @@ export function register(deps: ToolDeps) { description: "Search memories in one space with a natural-language query. Returns relevant memories plus that space's profile summary. When the user names a space, resolve it with listSpaces and pass containerTag; otherwise use the active space.", inputSchema, + outputSchema: searchMemoryOutputSchema, annotations: READ_ONLY_TOOL_ANNOTATIONS, }, async (args) => { @@ -28,9 +33,11 @@ export function register(deps: ToolDeps) { const client = deps.getClient(effectiveTag) const parts: string[] = [] + let profile: SearchMemoryOutput["profile"] if (args.includeProfile !== false) { const profileResult = await client.getProfile(args.query) + profile = profileResult.profile if (profileResult.profile.static.length > 0) { parts.push("## Profile") @@ -48,6 +55,12 @@ export function register(deps: ToolDeps) { } const searchResult = await client.search(args.query) + const results = searchResult.results.map((result) => ({ + id: result.id, + text: getMemoryText(result), + similarity: result.similarity, + ...(result.title ? { title: result.title } : {}), + })) if (searchResult.results.length > 0) { parts.push("\n## Matching memories") @@ -60,8 +73,18 @@ export function register(deps: ToolDeps) { parts.push("\nNo matching memories found.") } + const structuredContent: SearchMemoryOutput = { + query: args.query, + containerTag: effectiveTag, + ...(profile ? { profile } : {}), + results, + total: searchResult.total, + timing: searchResult.timing, + } + return { - content: [{ type: "text" as const, text: parts.join("\n") }], + content: [textContent(parts.join("\n"))], + structuredContent, } } catch (error) { return deps.errorResult(error) diff --git a/apps/mcp/src/server/tools/select-space.ts b/apps/mcp/src/server/tools/select-space.ts index 1a874473..0eadeb3b 100644 --- a/apps/mcp/src/server/tools/select-space.ts +++ b/apps/mcp/src/server/tools/select-space.ts @@ -3,7 +3,7 @@ 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" -import type { ToolDeps } from "./types" +import { textContent, type ToolDeps } from "./types" export function register(deps: ToolDeps) { deps.server.registerTool( @@ -41,10 +41,9 @@ export function register(deps: ToolDeps) { return { content: [ - { - type: "text" as const, - text: `${tags.length} spaces available. Select one to set your active context.`, - }, + textContent( + `${tags.length} spaces available. Select one to set your active context.`, + ), ], structuredContent: sc, _meta: appResultMeta(viewId), diff --git a/apps/mcp/src/server/tools/set-active-tag.ts b/apps/mcp/src/server/tools/set-active-tag.ts index 4db411e2..beb3b5f6 100644 --- a/apps/mcp/src/server/tools/set-active-tag.ts +++ b/apps/mcp/src/server/tools/set-active-tag.ts @@ -3,7 +3,7 @@ import { confirmationViewSchema, type ViewMessage } from "../../shared/types" import { appResultMeta, appToolMeta } from "../app-metadata" import { containerTagSchema } from "../container-tag" import { SETTINGS_TOOL_ANNOTATIONS } from "./annotations" -import type { ToolDeps } from "./types" +import { textContent, type ToolDeps } from "./types" export function register(deps: ToolDeps) { deps.server.registerTool( @@ -35,12 +35,7 @@ export function register(deps: ToolDeps) { containerTag, } return { - content: [ - { - type: "text" as const, - text: `Active space set to ${containerTag}`, - }, - ], + content: [textContent(`Active space set to ${containerTag}`)], structuredContent: sc, _meta: appResultMeta(viewId), } diff --git a/apps/mcp/src/server/tools/types.ts b/apps/mcp/src/server/tools/types.ts index 3ccb4cb7..0d07b1f5 100644 --- a/apps/mcp/src/server/tools/types.ts +++ b/apps/mcp/src/server/tools/types.ts @@ -1,4 +1,9 @@ -import type { McpServer, ServerContext } from "@modelcontextprotocol/server" +import type { + CallToolResult, + McpServer, + ServerContext, + TextContent, +} from "@modelcontextprotocol/server" import type { SessionInfo } from "../../shared/types" import type { SupermemoryClient } from "../client" import type { ActorContext } from "../types" @@ -10,23 +15,24 @@ export interface ToolDeps { actor: ActorContext getClient: (containerTag?: string) => SupermemoryClient getSession: () => Promise - resolveContainerTag: (explicit?: string) => Promise + resolveContainerTag: (explicit?: string) => Promise getActiveContainerTag: () => Promise setActiveContainerTag: (containerTag: string) => Promise getClientInfo: ( context: ServerContext, ) => { name: string; version?: string } | null - errorResult: (error: unknown) => { - content: { type: "text"; text: string }[] - isError: true - } + errorResult: (error: unknown) => CallToolResult } -export function errorResult(error: unknown) { +export function textContent(text: string): TextContent { + return { type: "text", text } +} + +export function errorResult(error: unknown): CallToolResult { const message = error instanceof Error ? error.message : "An unexpected error occurred" return { - content: [{ type: "text" as const, text: `Error: ${message}` }], - isError: true as const, + content: [textContent(`Error: ${message}`)], + isError: true, } } diff --git a/apps/mcp/src/server/tools/upload-file-submit.ts b/apps/mcp/src/server/tools/upload-file-submit.ts index d5347313..3c534427 100644 --- a/apps/mcp/src/server/tools/upload-file-submit.ts +++ b/apps/mcp/src/server/tools/upload-file-submit.ts @@ -3,7 +3,7 @@ import { uploadSuccessViewSchema, type ViewMessage } from "../../shared/types" import { appResultMeta, appToolMeta } from "../app-metadata" import { containerTagSchema } from "../container-tag" import { ADDITIVE_MEMORY_TOOL_ANNOTATIONS } from "./annotations" -import type { ToolDeps } from "./types" +import { textContent, type ToolDeps } from "./types" export function register(deps: ToolDeps) { deps.server.registerTool( @@ -48,10 +48,7 @@ export function register(deps: ToolDeps) { return { content: [ - { - type: "text" as const, - text: `File uploaded: ${args.fileName} → ${result.id}`, - }, + textContent(`File uploaded: ${args.fileName} → ${result.id}`), ], structuredContent: sc, _meta: appResultMeta(viewId), diff --git a/apps/mcp/src/server/tools/upload-file.ts b/apps/mcp/src/server/tools/upload-file.ts index 2ada7d69..b9c98118 100644 --- a/apps/mcp/src/server/tools/upload-file.ts +++ b/apps/mcp/src/server/tools/upload-file.ts @@ -3,7 +3,7 @@ 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" -import type { ToolDeps } from "./types" +import { textContent, type ToolDeps } from "./types" export function register(deps: ToolDeps) { deps.server.registerTool( @@ -40,9 +40,7 @@ export function register(deps: ToolDeps) { } return { - content: [ - { type: "text" as const, text: "Opening file upload form..." }, - ], + content: [textContent("Opening file upload form...")], structuredContent: sc, _meta: appResultMeta(viewId), } diff --git a/apps/mcp/src/server/tools/who-am-i.ts b/apps/mcp/src/server/tools/who-am-i.ts index 0b71a30e..629d1a09 100644 --- a/apps/mcp/src/server/tools/who-am-i.ts +++ b/apps/mcp/src/server/tools/who-am-i.ts @@ -1,6 +1,7 @@ import { z } from "zod" import { READ_ONLY_TOOL_ANNOTATIONS } from "./annotations" -import type { ToolDeps } from "./types" +import { whoAmIOutputSchema, type WhoAmIOutput } from "./output-schemas" +import { textContent, type ToolDeps } from "./types" export function register(deps: ToolDeps) { deps.server.registerTool( @@ -9,6 +10,7 @@ export function register(deps: ToolDeps) { 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({}), + outputSchema: whoAmIOutputSchema, annotations: READ_ONLY_TOOL_ANNOTATIONS, }, async (_args, context) => { @@ -19,27 +21,24 @@ export function register(deps: ToolDeps) { ]) const client = deps.getClientInfo(context) const sessionId = context.sessionId + const structuredContent: WhoAmIOutput = { + userId: session.user.id, + ...(session.user.email ? { email: session.user.email } : {}), + ...(session.user.name ? { name: session.user.name } : {}), + role: session.role ?? "unknown", + accessType: session.accessType ?? "full", + activeSpace: activeTag ?? null, + assignedSpaces: + session.accessType === "restricted" + ? (session.containerTags ?? null) + : null, + ...(session.scope ? { scope: session.scope } : {}), + ...(client ? { client } : {}), + ...(sessionId ? { sessionId } : {}), + } return { - content: [ - { - type: "text" as const, - text: JSON.stringify({ - userId: session.user.id, - email: session.user.email, - name: session.user.name, - role: session.role ?? "unknown", - accessType: session.accessType ?? "full", - activeSpace: activeTag ?? null, - assignedSpaces: - session.accessType === "restricted" - ? session.containerTags - : null, - scope: session.scope, - ...(client ? { client } : {}), - ...(sessionId ? { sessionId } : {}), - }), - }, - ], + content: [textContent(JSON.stringify(structuredContent))], + structuredContent, } } catch (error) { return deps.errorResult(error) diff --git a/apps/mcp/src/server/widget-manifest.d.ts b/apps/mcp/src/server/widget-manifest.d.ts new file mode 100644 index 00000000..d1895ddd --- /dev/null +++ b/apps/mcp/src/server/widget-manifest.d.ts @@ -0,0 +1,8 @@ +declare module "*widget-manifest.json" { + const manifest: { + resourceUri: string + sha256: string + } + + export default manifest +} diff --git a/apps/mcp/src/server/widget-resource-metadata.ts b/apps/mcp/src/server/widget-resource-metadata.ts new file mode 100644 index 00000000..fdc74f0f --- /dev/null +++ b/apps/mcp/src/server/widget-resource-metadata.ts @@ -0,0 +1,24 @@ +export const WIDGET_DESCRIPTION = + "Interactive Supermemory view for memory graphs, space selection, guided saves, file uploads, and confirmations." + +const WIDGET_DOMAIN = "https://mcp.supermemory.ai" + +export const WIDGET_RESOURCE_UI_META = { + prefersBorder: true, + csp: { + resourceDomains: [ + "https://fonts.googleapis.com", + "https://fonts.gstatic.com", + ], + connectDomains: [ + "https://fonts.googleapis.com", + "https://fonts.gstatic.com", + ], + }, +} + +export const WIDGET_RESOURCE_META = { + ui: WIDGET_RESOURCE_UI_META, + "openai/widgetDescription": WIDGET_DESCRIPTION, + "openai/widgetDomain": WIDGET_DOMAIN, +} diff --git a/apps/mcp/src/shared/types.ts b/apps/mcp/src/shared/types.ts index fb063491..83e61845 100644 --- a/apps/mcp/src/shared/types.ts +++ b/apps/mcp/src/shared/types.ts @@ -169,8 +169,13 @@ export const graphViewSchema = z.object({ view: z.literal("graph"), viewId: viewIdSchema, containerTag: z.string().optional(), - documents: z.array(documentWithMemoriesSchema), - totalCount: z.number().int().nonnegative(), + documents: z.array(documentWithMemoriesSchema).optional(), + totalCount: z.number().int().nonnegative().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", [ @@ -185,7 +190,12 @@ export const viewMessageSchema = z.discriminatedUnion("view", [ export type ViewMessage = z.infer -export type ViewName = ViewMessage["view"] +export const graphResultMetaSchema = z.looseObject({ + graphData: z.object({ + documents: z.array(documentWithMemoriesSchema), + }), +}) -// Hosts cache MCP UI resources by URI, so bump this when shipping a new widget bundle. -export const SUPERMEMORY_RESOURCE_URI = "ui://supermemory/app-v4.html" +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 d0a7e2df..bd06ee82 100644 --- a/apps/mcp/src/widget/App.tsx +++ b/apps/mcp/src/widget/App.tsx @@ -130,7 +130,13 @@ function renderView( /> ) case "graph": - return + return ( + + ) case "confirmation": return case "save-success": diff --git a/apps/mcp/src/widget/hooks/useApp.ts b/apps/mcp/src/widget/hooks/useApp.ts index 482a1fc8..2d8b6de0 100644 --- a/apps/mcp/src/widget/hooks/useApp.ts +++ b/apps/mcp/src/widget/hooks/useApp.ts @@ -1,5 +1,5 @@ import { useContext, useMemo } from "react" -import { viewMessageSchema, type ViewMessage } from "../../shared/types" +import type { ZodType } from "zod" import { handoffToModel as performModelHandoff, type ModelHandoffRequest, @@ -26,15 +26,16 @@ export function useApp() { if (!context) { throw new Error("useApp must be used within McpAppProvider") } - const { app } = context + const { app, isConnected } = context return useMemo(() => { return { /** Call an MCP server tool and await the result. */ - async callTool( + async callTool( name: string, args: Record, - ): Promise> { + schema: ZodType, + ): Promise> { if (!app) { return { ok: false, error: "MCP host is not connected" } } @@ -50,7 +51,7 @@ export function useApp() { : "Tool returned an error" return { ok: false, error: text } } - const parsed = viewMessageSchema.safeParse(result.structuredContent) + const parsed = schema.safeParse(result.structuredContent) if (!parsed.success) { return { ok: false, @@ -66,6 +67,8 @@ export function useApp() { } }, + isConnected, + /** Make widget state available to the model on a future turn. */ async updateModelContext(content: string): Promise { if (!app) { @@ -116,7 +119,7 @@ export function useApp() { return app?.getHostContext() }, } - }, [app]) + }, [app, isConnected]) } export type AppApi = ReturnType diff --git a/apps/mcp/src/widget/studio/Studio.tsx b/apps/mcp/src/widget/studio/Studio.tsx index 54d83fb9..f44eec33 100644 --- a/apps/mcp/src/widget/studio/Studio.tsx +++ b/apps/mcp/src/widget/studio/Studio.tsx @@ -425,8 +425,8 @@ export function Studio() { diff --git a/apps/mcp/src/widget/views/Graph.tsx b/apps/mcp/src/widget/views/Graph.tsx index c584f211..5b61c5f3 100644 --- a/apps/mcp/src/widget/views/Graph.tsx +++ b/apps/mcp/src/widget/views/Graph.tsx @@ -6,15 +6,18 @@ import { MemoryGraph, } from "@supermemory/memory-graph" import { useCallback, useEffect, useMemo, useRef, useState } from "react" -import type { - DocumentMemoryEntry, - DocumentWithMemories, +import { + documentsApiResponseSchema, + type DocumentMemoryEntry, + type DocumentWithMemories, } from "../../shared/types" import { cn } from "../design/lib/cn" import { useApp } from "../hooks/useApp" import { useHostContext } from "../hooks/useHostContext" import { useLog } from "../hooks/useLog" import { ArrowsIn, ArrowsOut } from "../lib/icons" +import { ErrorView } from "./Error" +import { Loading } from "./Loading" // GraphThemeColors key → the --graph-* CSS variable it resolves from. Same // mapping as the package's internal useGraphTheme, but we drive it ourselves @@ -81,8 +84,9 @@ function useGraphColors(theme: string): GraphThemeColors { } interface Props { - documents: DocumentWithMemories[] - totalCount: number + containerTag?: string + initialDocuments?: DocumentWithMemories[] + initialTotalCount: number } // Map the widget's API shape (DocumentWithMemories) into the package's @@ -123,13 +127,48 @@ function toGraphDocument(doc: DocumentWithMemories): GraphApiDocument { } } -export function Graph({ documents, totalCount }: Props) { - const { requestDisplayMode } = useApp() +export function Graph({ + containerTag, + initialDocuments, + initialTotalCount, +}: Props) { + const { callTool, isConnected, requestDisplayMode } = useApp() const ctx = useHostContext() const log = useLog() + const [documents, setDocuments] = useState(initialDocuments) + const [totalCount, setTotalCount] = useState(initialTotalCount) + const [loadError, setLoadError] = useState(null) + + useEffect(() => { + if (!isConnected) return + let active = true + void callTool( + "fetch-graph-data", + { + ...(containerTag ? { containerTag } : {}), + page: 1, + limit: 200, + }, + documentsApiResponseSchema, + ).then((result) => { + if (!active) return + if (!result.ok || !result.data) { + if (!initialDocuments) { + setLoadError(result.error ?? "Failed to load graph data") + } + return + } + setDocuments(result.data.documents) + setTotalCount(result.data.pagination.totalItems) + setLoadError(null) + }) + return () => { + active = false + } + }, [callTool, containerTag, initialDocuments, isConnected]) const graphDocuments = useMemo( - () => documents.map(toGraphDocument), + () => (documents ?? []).map(toGraphDocument), [documents], ) @@ -190,6 +229,9 @@ export function Graph({ documents, totalCount }: Props) { return () => document.removeEventListener("keydown", handler) }, [mode, toggleFullscreen]) + if (loadError) return + if (!documents) return + return (
{ log("info", `[picker] select: ${containerTag}`) setPending(containerTag) - const result = await callTool("set-active-tag", { - containerTag, - viewId, - }) + const result = await callTool( + "set-active-tag", + { + containerTag, + viewId, + }, + viewMessageSchema, + ) setPending(null) if (!result.ok || !result.data) { log("error", `[picker] set-active-tag failed: ${result.error}`) diff --git a/apps/mcp/src/widget/views/Save.tsx b/apps/mcp/src/widget/views/Save.tsx index 24919e66..0400291b 100644 --- a/apps/mcp/src/widget/views/Save.tsx +++ b/apps/mcp/src/widget/views/Save.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from "react" -import type { ViewMessage } from "../../shared/types" +import { viewMessageSchema, type ViewMessage } from "../../shared/types" import { ActionGroup, Button, @@ -61,11 +61,15 @@ export function Save({ if (!canSave || !selectedTag) return log("info", `[save] submit (${trimmed.length} chars → ${selectedTag})`) setSaving(true) - const result = await callTool("save-memory", { - content: trimmed, - containerTag: selectedTag, - viewId, - }) + const result = await callTool( + "save-memory", + { + content: trimmed, + containerTag: selectedTag, + viewId, + }, + viewMessageSchema, + ) setSaving(false) if (!result.ok || !result.data) { log("error", `[save] failed: ${result.error}`) diff --git a/apps/mcp/src/widget/views/Upload.tsx b/apps/mcp/src/widget/views/Upload.tsx index 9356765e..e69e8f7f 100644 --- a/apps/mcp/src/widget/views/Upload.tsx +++ b/apps/mcp/src/widget/views/Upload.tsx @@ -1,5 +1,5 @@ import { useMemo, useState } from "react" -import type { ViewMessage } from "../../shared/types" +import { viewMessageSchema, type ViewMessage } from "../../shared/types" import { ActionGroup, Button, @@ -65,13 +65,17 @@ export function Upload({ setUploading(true) try { const fileData = await readFileAsBase64(file) - const result = await callTool("upload-file-submit", { - fileData, - fileName: file.name, - mimeType: file.type, - containerTag: selectedTag, - viewId, - }) + const result = await callTool( + "upload-file-submit", + { + fileData, + fileName: file.name, + mimeType: file.type, + containerTag: selectedTag, + viewId, + }, + viewMessageSchema, + ) if (!result.ok || !result.data) { log("error", `[upload] failed: ${result.error}`) onError(result.error ?? "Upload failed")