mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
fix mcp app contracts (#1394)
Fixes MCP app submission metadata and makes widget delivery consistent across hosts. - content-hash widget resources and set the production widget domain - add typed structured outputs to direct tools and scope default-space calls - keep graph rendering compatible with both initial results and app-side loading Tested with `bun run check-types` and `bun run test:unit` (42 tests).
This commit is contained in:
parent
68769200b0
commit
7e7e820489
37 changed files with 587 additions and 205 deletions
|
|
@ -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-<sha256>.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
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -168,6 +168,7 @@ export async function exchangeRefreshToken(
|
|||
export type CallResult = {
|
||||
content?: Array<{ type: string; text?: string }>
|
||||
structuredContent?: unknown
|
||||
_meta?: unknown
|
||||
isError?: boolean
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
29
apps/mcp/scripts/build-widget.ts
Normal file
29
apps/mcp/scripts/build-widget.ts
Normal file
|
|
@ -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`,
|
||||
)
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
155
apps/mcp/src/server/tools/output-schemas.ts
Normal file
155
apps/mcp/src/server/tools/output-schemas.ts
Normal file
|
|
@ -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<typeof addMemoryOutputSchema>
|
||||
|
||||
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<typeof getDocumentOutputSchema>
|
||||
|
||||
export const listDocumentsOutputSchema = z.object({
|
||||
documents: z.array(documentSummarySchema),
|
||||
pagination: paginationSchema,
|
||||
})
|
||||
|
||||
export type ListDocumentsOutput = z.infer<typeof listDocumentsOutputSchema>
|
||||
|
||||
export const listMemoriesOutputSchema = z.object({
|
||||
memoryEntries: z.array(memoryEntryOutputSchema),
|
||||
pagination: paginationSchema,
|
||||
})
|
||||
|
||||
export type ListMemoriesOutput = z.infer<typeof listMemoriesOutputSchema>
|
||||
|
||||
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<typeof searchMemoryOutputSchema>
|
||||
|
||||
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<typeof whoAmIOutputSchema>
|
||||
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<SessionInfo>
|
||||
resolveContainerTag: (explicit?: string) => Promise<string | undefined>
|
||||
resolveContainerTag: (explicit?: string) => Promise<string>
|
||||
getActiveContainerTag: () => Promise<string | undefined>
|
||||
setActiveContainerTag: (containerTag: string) => Promise<void>
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
8
apps/mcp/src/server/widget-manifest.d.ts
vendored
Normal file
8
apps/mcp/src/server/widget-manifest.d.ts
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
declare module "*widget-manifest.json" {
|
||||
const manifest: {
|
||||
resourceUri: string
|
||||
sha256: string
|
||||
}
|
||||
|
||||
export default manifest
|
||||
}
|
||||
24
apps/mcp/src/server/widget-resource-metadata.ts
Normal file
24
apps/mcp/src/server/widget-resource-metadata.ts
Normal file
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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<typeof viewMessageSchema>
|
||||
|
||||
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<typeof graphResultMetaSchema>
|
||||
|
||||
export type ViewName = ViewMessage["view"]
|
||||
|
|
|
|||
|
|
@ -130,7 +130,13 @@ function renderView(
|
|||
/>
|
||||
)
|
||||
case "graph":
|
||||
return <Graph documents={msg.documents} totalCount={msg.totalCount} />
|
||||
return (
|
||||
<Graph
|
||||
containerTag={msg.containerTag}
|
||||
initialDocuments={msg.documents}
|
||||
initialTotalCount={msg.totalCount ?? msg.totalDocumentCount}
|
||||
/>
|
||||
)
|
||||
case "confirmation":
|
||||
return <Confirmation containerTag={msg.containerTag} />
|
||||
case "save-success":
|
||||
|
|
|
|||
|
|
@ -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<T>(
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
): Promise<ToolCallResult<ViewMessage>> {
|
||||
schema: ZodType<T>,
|
||||
): Promise<ToolCallResult<T>> {
|
||||
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<ToolCallResult> {
|
||||
if (!app) {
|
||||
|
|
@ -116,7 +119,7 @@ export function useApp() {
|
|||
return app?.getHostContext()
|
||||
},
|
||||
}
|
||||
}, [app])
|
||||
}, [app, isConnected])
|
||||
}
|
||||
|
||||
export type AppApi = ReturnType<typeof useApp>
|
||||
|
|
|
|||
|
|
@ -425,8 +425,8 @@ export function Studio() {
|
|||
<Frame label="Graph (@supermemory/memory-graph)" width={frameWidth}>
|
||||
<WidgetShell immersive>
|
||||
<Graph
|
||||
documents={mockDocuments}
|
||||
totalCount={mockDocuments.length}
|
||||
initialDocuments={mockDocuments}
|
||||
initialTotalCount={mockDocuments.length}
|
||||
/>
|
||||
</WidgetShell>
|
||||
</Frame>
|
||||
|
|
|
|||
|
|
@ -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<string | null>(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 <ErrorView message={loadError} />
|
||||
if (!documents) return <Loading />
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { useMemo, useState } from "react"
|
||||
import type {
|
||||
ContainerTag,
|
||||
ContainerTagAccess,
|
||||
ViewMessage,
|
||||
import {
|
||||
type ContainerTag,
|
||||
type ContainerTagAccess,
|
||||
viewMessageSchema,
|
||||
type ViewMessage,
|
||||
} from "../../shared/types"
|
||||
import { SpaceCard } from "../components/SpaceCard"
|
||||
import { Input, PageHeader } from "../design/ui"
|
||||
|
|
@ -49,10 +50,14 @@ export function Picker({
|
|||
const handleSelect = async (containerTag: string) => {
|
||||
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}`)
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue