mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
fix MCP app result contracts (#1385)
Moves MCP App results onto explicit runtime schemas so hosts can distinguish model-visible output from widget-only graph data. - advertises output schemas for structured tools - validates API, session, and widget boundaries instead of asserting response types - marks additive app writes as non-destructive and removes redundant widget typing Verified with MCP typecheck, 42 unit tests, and the production widget build. Claude Desktop host validation is in progress against a temporary tunnel.
This commit is contained in:
parent
e8ed80f768
commit
9e194fbc50
35 changed files with 512 additions and 398 deletions
|
|
@ -1,4 +1,5 @@
|
|||
import { afterAll, beforeAll, describe, expect, it } from "vitest"
|
||||
import { graphViewSchema } from "../src/shared/types"
|
||||
import {
|
||||
OAUTH_CREDENTIALS_AVAILABLE,
|
||||
callTool,
|
||||
|
|
@ -18,17 +19,16 @@ describeWithAuth("MCP — graph, resources & prompts", () => {
|
|||
await s?.close()
|
||||
})
|
||||
|
||||
it("memory-graph returns a summary + structured documents", async () => {
|
||||
it("memory-graph returns a rendered widget summary", async () => {
|
||||
const res = await callTool(s.client, "memory-graph")
|
||||
expect(res.isError).toBeFalsy()
|
||||
expect(textOf(res)).toMatch(
|
||||
/Rendered the interactive Memory Graph MCP App: \d+ documents/,
|
||||
/The interactive Memory Graph MCP App is rendered and visible: \d+ documents/,
|
||||
)
|
||||
const sc = res.structuredContent as {
|
||||
documents?: unknown[]
|
||||
totalCount?: number
|
||||
}
|
||||
expect(Array.isArray(sc?.documents)).toBe(true)
|
||||
const result = graphViewSchema.safeParse(res.structuredContent)
|
||||
expect(result.success).toBe(true)
|
||||
if (!result.success) throw result.error
|
||||
expect(result.data.rendered).toBe(true)
|
||||
})
|
||||
|
||||
it("fetch-graph-data returns paginated documents", async () => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { createRemoteJWKSet, jwtVerify, type JWTVerifyGetKey } from "jose"
|
||||
import type { SessionInfo } from "../../shared/types"
|
||||
import { sessionInfoSchema, type SessionInfo } from "../../shared/types"
|
||||
|
||||
const FETCH_TIMEOUT_MS = 30_000
|
||||
|
||||
|
|
@ -44,12 +44,12 @@ export async function fetchSession(
|
|||
)
|
||||
}
|
||||
|
||||
const session = (await response.json()) as SessionInfo | null
|
||||
if (!session?.user?.id) {
|
||||
throw new Error("Missing user.id in session response")
|
||||
const result = sessionInfoSchema.safeParse(await response.json())
|
||||
if (!result.success) {
|
||||
throw new Error("Invalid session response")
|
||||
}
|
||||
|
||||
return session
|
||||
return result.data
|
||||
}
|
||||
|
||||
export async function validateOAuthToken(
|
||||
|
|
|
|||
|
|
@ -3,11 +3,15 @@ import type {
|
|||
DocumentGetResponse,
|
||||
DocumentListResponse as SdkDocumentListResponse,
|
||||
} from "supermemory/resources/documents"
|
||||
import type {
|
||||
ContainerTag,
|
||||
DocumentMemoryEntry,
|
||||
DocumentsApiResponse,
|
||||
DocumentWithMemories,
|
||||
import { z } from "zod"
|
||||
import {
|
||||
containerTagSchema,
|
||||
documentsApiResponseSchema,
|
||||
paginationSchema,
|
||||
type ContainerTag,
|
||||
type DocumentMemoryEntry,
|
||||
type DocumentsApiResponse,
|
||||
type DocumentWithMemories,
|
||||
} from "../../shared/types"
|
||||
|
||||
const MAX_CHARS = 200000
|
||||
|
|
@ -30,42 +34,43 @@ export interface DocumentsListResponse {
|
|||
pagination: SdkDocumentListResponse["pagination"]
|
||||
}
|
||||
|
||||
export interface MemoryEntryHistory {
|
||||
id: string
|
||||
memory: string
|
||||
version: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
parentMemoryId?: string | null
|
||||
rootMemoryId?: string | null
|
||||
isLatest?: boolean
|
||||
isForgotten?: boolean
|
||||
}
|
||||
const memoryEntryHistorySchema = z.looseObject({
|
||||
id: z.string(),
|
||||
memory: z.string(),
|
||||
version: z.number(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
parentMemoryId: z.string().nullish(),
|
||||
rootMemoryId: z.string().nullish(),
|
||||
isLatest: z.boolean().optional(),
|
||||
isForgotten: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export interface MemoryEntry {
|
||||
id: string
|
||||
memory: string
|
||||
version: number
|
||||
isLatest: boolean
|
||||
isForgotten: boolean
|
||||
isStatic?: boolean
|
||||
isInference?: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
sourceCount?: number
|
||||
documentIds?: string[]
|
||||
history?: MemoryEntryHistory[]
|
||||
}
|
||||
export type MemoryEntryHistory = z.infer<typeof memoryEntryHistorySchema>
|
||||
|
||||
export interface MemoryEntriesResponse {
|
||||
memoryEntries: MemoryEntry[]
|
||||
pagination: {
|
||||
currentPage: number
|
||||
limit: number
|
||||
totalItems: number
|
||||
totalPages: number
|
||||
}
|
||||
}
|
||||
const memoryEntrySchema = z.looseObject({
|
||||
id: z.string(),
|
||||
memory: z.string(),
|
||||
version: z.number(),
|
||||
isLatest: z.boolean(),
|
||||
isForgotten: z.boolean(),
|
||||
isStatic: z.boolean().optional(),
|
||||
isInference: z.boolean().optional(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
sourceCount: z.number().optional(),
|
||||
documentIds: z.array(z.string()).optional(),
|
||||
history: z.array(memoryEntryHistorySchema).optional(),
|
||||
})
|
||||
|
||||
export type MemoryEntry = z.infer<typeof memoryEntrySchema>
|
||||
|
||||
const memoryEntriesResponseSchema = z.object({
|
||||
memoryEntries: z.array(memoryEntrySchema),
|
||||
pagination: paginationSchema,
|
||||
})
|
||||
|
||||
export type MemoryEntriesResponse = z.infer<typeof memoryEntriesResponseSchema>
|
||||
|
||||
export type Memory =
|
||||
| {
|
||||
|
|
@ -107,14 +112,46 @@ function limitByChars(text: string, maxChars = MAX_CHARS): string {
|
|||
return text.length > maxChars ? `${text.slice(0, maxChars)}...` : text
|
||||
}
|
||||
|
||||
interface SDKResult {
|
||||
id: string
|
||||
memory?: string
|
||||
chunk?: string
|
||||
content?: string
|
||||
similarity: number
|
||||
title?: string
|
||||
context?: string
|
||||
const sdkResultSchema = z.looseObject({
|
||||
id: z.string(),
|
||||
memory: z.string().nullish(),
|
||||
chunk: z.string().nullish(),
|
||||
content: z.string().nullish(),
|
||||
similarity: z.number(),
|
||||
title: z.string().nullish(),
|
||||
context: z.string().nullish(),
|
||||
})
|
||||
|
||||
const uploadResultSchema = z.object({
|
||||
id: z.string(),
|
||||
status: z.string(),
|
||||
})
|
||||
|
||||
function mapSdkResults(value: unknown): Memory[] {
|
||||
return z
|
||||
.array(sdkResultSchema)
|
||||
.parse(value)
|
||||
.map((result) => {
|
||||
const text = limitByChars(
|
||||
result.content || result.memory || result.chunk || result.context || "",
|
||||
)
|
||||
const base = {
|
||||
id: result.id,
|
||||
similarity: result.similarity,
|
||||
...(result.title ? { title: result.title } : {}),
|
||||
...(result.content ? { content: result.content } : {}),
|
||||
}
|
||||
if (result.chunk && !result.memory) {
|
||||
return { ...base, chunk: text }
|
||||
}
|
||||
return { ...base, memory: text }
|
||||
})
|
||||
}
|
||||
|
||||
function objectProperty(value: unknown, key: string): unknown {
|
||||
return value && typeof value === "object"
|
||||
? Reflect.get(value, key)
|
||||
: undefined
|
||||
}
|
||||
|
||||
export class SupermemoryClient {
|
||||
|
|
@ -175,10 +212,7 @@ export class SupermemoryClient {
|
|||
containerTag: this.containerTag,
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const status =
|
||||
error && typeof error === "object" && "status" in error
|
||||
? (error as Record<string, unknown>).status
|
||||
: undefined
|
||||
const status = objectProperty(error, "status")
|
||||
if (status !== 404) throw error
|
||||
}
|
||||
|
||||
|
|
@ -242,23 +276,11 @@ export class SupermemoryClient {
|
|||
threshold,
|
||||
})
|
||||
|
||||
const results: Memory[] = (result.results as SDKResult[]).map((r) => {
|
||||
const text = limitByChars(
|
||||
r.content || r.memory || r.chunk || r.context || "",
|
||||
)
|
||||
const base = {
|
||||
id: r.id,
|
||||
similarity: r.similarity,
|
||||
title: r.title,
|
||||
content: r.content,
|
||||
}
|
||||
if (r.chunk && !r.memory) {
|
||||
return { ...base, chunk: text }
|
||||
}
|
||||
return { ...base, memory: text }
|
||||
})
|
||||
|
||||
return { results, total: result.total, timing: result.timing }
|
||||
return {
|
||||
results: mapSdkResults(result.results),
|
||||
total: result.total,
|
||||
timing: result.timing,
|
||||
}
|
||||
} catch (error) {
|
||||
this.handleOperationError("Search request", error)
|
||||
}
|
||||
|
|
@ -289,19 +311,7 @@ export class SupermemoryClient {
|
|||
|
||||
if (result.searchResults) {
|
||||
response.searchResults = {
|
||||
results: (result.searchResults.results as SDKResult[]).map((r) => {
|
||||
const text = limitByChars(
|
||||
r.content || r.memory || r.chunk || r.context || "",
|
||||
)
|
||||
const base = {
|
||||
id: r.id,
|
||||
similarity: r.similarity,
|
||||
title: r.title,
|
||||
content: r.content,
|
||||
}
|
||||
if (r.chunk && !r.memory) return { ...base, chunk: text }
|
||||
return { ...base, memory: text }
|
||||
}),
|
||||
results: mapSdkResults(result.searchResults.results),
|
||||
total: result.searchResults.total,
|
||||
timing: result.searchResults.timing,
|
||||
}
|
||||
|
|
@ -335,8 +345,7 @@ export class SupermemoryClient {
|
|||
)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as ContainerTag[]
|
||||
return Array.isArray(data) ? data : []
|
||||
return z.array(containerTagSchema).parse(await response.json())
|
||||
} catch (error) {
|
||||
this.handleError(error)
|
||||
}
|
||||
|
|
@ -371,7 +380,7 @@ export class SupermemoryClient {
|
|||
status: response.status,
|
||||
})
|
||||
}
|
||||
return (await response.json()) as DocumentsApiResponse
|
||||
return documentsApiResponseSchema.parse(await response.json())
|
||||
} catch (error) {
|
||||
this.handleError(error)
|
||||
}
|
||||
|
|
@ -435,7 +444,7 @@ export class SupermemoryClient {
|
|||
)
|
||||
}
|
||||
|
||||
return (await response.json()) as MemoryEntriesResponse
|
||||
return memoryEntriesResponseSchema.parse(await response.json())
|
||||
} catch (error) {
|
||||
this.handleError(error)
|
||||
}
|
||||
|
|
@ -472,8 +481,7 @@ export class SupermemoryClient {
|
|||
})
|
||||
}
|
||||
|
||||
const result = (await response.json()) as { id: string; status: string }
|
||||
return result
|
||||
return uploadResultSchema.parse(await response.json())
|
||||
} catch (error) {
|
||||
this.handleError(error)
|
||||
}
|
||||
|
|
@ -498,11 +506,10 @@ export class SupermemoryClient {
|
|||
}
|
||||
}
|
||||
|
||||
if (error && typeof error === "object" && "status" in error) {
|
||||
const status = (error as { status: number }).status
|
||||
const message =
|
||||
"message" in error ? (error as { message: string }).message : undefined
|
||||
|
||||
const status = objectProperty(error, "status")
|
||||
if (typeof status === "number") {
|
||||
const rawMessage = objectProperty(error, "message")
|
||||
const message = typeof rawMessage === "string" ? rawMessage : undefined
|
||||
switch (status) {
|
||||
case 400:
|
||||
case 422:
|
||||
|
|
|
|||
|
|
@ -9,5 +9,5 @@ export const containerTagSchema = z
|
|||
export const optionalContainerTagSchema = containerTagSchema
|
||||
.optional()
|
||||
.describe(
|
||||
"Space key to use for this call. If the user names a space, call listSpaces to resolve its key and pass it here. Omit only when the user means the active space.",
|
||||
"Space key to use for this call. If the user names a space, call listSpaces to resolve its key and pass it here. If no space is named, omit this field so the server uses the active space or account default.",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import type { AuthInfo } from "@modelcontextprotocol/server"
|
|||
import { createMcpHandler } from "agents/mcp/server"
|
||||
import { Hono, type Context } from "hono"
|
||||
import { cors } from "hono/cors"
|
||||
import type { ContentfulStatusCode } from "hono/utils/http-status"
|
||||
import { validateOAuthToken, type AuthUser } from "./auth"
|
||||
import { SupermemoryMCP } from "./legacy-protocol-state"
|
||||
import { createSupermemoryServer } from "./server"
|
||||
|
|
@ -82,9 +81,9 @@ app.get("/.well-known/oauth-authorization-server", async (c) => {
|
|||
`${apiUrl}/.well-known/oauth-authorization-server`,
|
||||
)
|
||||
if (!response.ok) {
|
||||
return c.json(
|
||||
return Response.json(
|
||||
{ error: "Failed to fetch authorization server metadata" },
|
||||
{ status: response.status as ContentfulStatusCode },
|
||||
{ status: response.status },
|
||||
)
|
||||
}
|
||||
return c.json(await response.json())
|
||||
|
|
|
|||
|
|
@ -7,7 +7,10 @@ const CSP_DOMAINS = [
|
|||
"https://esm.sh",
|
||||
"https://fonts.googleapis.com",
|
||||
"https://fonts.gstatic.com",
|
||||
] as const
|
||||
]
|
||||
|
||||
const WIDGET_DESCRIPTION =
|
||||
"Interactive Supermemory view. The tool result identifies whether the mounted app is a memory graph, space picker, save form, upload form, or confirmation. When rendered is true, the interface is already visible to the user."
|
||||
|
||||
const RESOURCE_UI_META = {
|
||||
prefersBorder: true,
|
||||
|
|
@ -17,6 +20,11 @@ const RESOURCE_UI_META = {
|
|||
},
|
||||
}
|
||||
|
||||
const RESOURCE_META = {
|
||||
ui: RESOURCE_UI_META,
|
||||
"openai/widgetDescription": WIDGET_DESCRIPTION,
|
||||
}
|
||||
|
||||
export function registerWidgetResource(server: McpServer) {
|
||||
server.registerResource(
|
||||
"Supermemory MCP UI",
|
||||
|
|
@ -26,7 +34,8 @@ export function registerWidgetResource(server: McpServer) {
|
|||
// so prefetch/connect-time decisions match what the host will get.
|
||||
{
|
||||
mimeType: APP_RESOURCE_MIME_TYPE,
|
||||
_meta: { ui: RESOURCE_UI_META },
|
||||
description: WIDGET_DESCRIPTION,
|
||||
_meta: RESOURCE_META,
|
||||
},
|
||||
// Read response: per spec, content-item `_meta.ui` takes precedence
|
||||
// over the listing-level value. Set both to the same object so behavior
|
||||
|
|
@ -37,7 +46,7 @@ export function registerWidgetResource(server: McpServer) {
|
|||
uri: SUPERMEMORY_RESOURCE_URI,
|
||||
mimeType: APP_RESOURCE_MIME_TYPE,
|
||||
text: supermemoryAppHtml,
|
||||
_meta: { ui: RESOURCE_UI_META },
|
||||
_meta: RESOURCE_META,
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -23,14 +23,15 @@ import {
|
|||
} from "./space"
|
||||
|
||||
const DEFAULT_API_URL = "https://api.supermemory.ai"
|
||||
const SERVER_INSTRUCTIONS =
|
||||
"Supermemory is the authenticated user's persistent memory and knowledge layer across conversations and spaces. Use these tools whenever the user wants to recall something they may have saved, inspect stored sources or extracted memories, remember or upload new information, check their Supermemory account or access, change their active space, or explore their memory graph, even if they do not mention Supermemory by name. Use the active or account-default space when none is named. Resolve a named space with listSpaces and pass its key to the relevant tool; change the active space only when the user explicitly asks."
|
||||
|
||||
type ClientInfo = { name: string; version?: string }
|
||||
|
||||
function clientInfoFromContext(context: ServerContext): ClientInfo | null {
|
||||
const envelope = context.mcpReq.envelope as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
const value = envelope?.[CLIENT_INFO_META_KEY]
|
||||
const envelope = context.mcpReq.envelope
|
||||
if (!envelope) return null
|
||||
const value = Reflect.get(envelope, CLIENT_INFO_META_KEY)
|
||||
if (!value || typeof value !== "object") return null
|
||||
|
||||
const name = Reflect.get(value, "name")
|
||||
|
|
@ -48,10 +49,13 @@ export function createSupermemoryServer(
|
|||
actor: ActorContext,
|
||||
waitUntil: WaitUntil,
|
||||
): McpServer {
|
||||
const server = new McpServer({
|
||||
name: "supermemory",
|
||||
version: "1.0.0",
|
||||
})
|
||||
const server = new McpServer(
|
||||
{
|
||||
name: "supermemory",
|
||||
version: "1.0.0",
|
||||
},
|
||||
{ instructions: SERVER_INSTRUCTIONS },
|
||||
)
|
||||
const apiUrl = env.API_URL || DEFAULT_API_URL
|
||||
const spaceState = env.SPACE_STATE.getByName(spaceStateName(actor))
|
||||
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ export function formatActivityDate(value: string | null): string | undefined {
|
|||
}
|
||||
|
||||
export function spaceMetadata(space: ContainerTag): string {
|
||||
const lastActivity = formatActivityDate(space.lastActivityAt)
|
||||
const lastActivity = formatActivityDate(space.lastActivityAt ?? null)
|
||||
const fields = [
|
||||
space.visibility
|
||||
? `${space.visibility.charAt(0).toUpperCase()}${space.visibility.slice(1)}`
|
||||
|
|
|
|||
|
|
@ -4,14 +4,21 @@ export const READ_ONLY_TOOL_ANNOTATIONS = {
|
|||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
} as const
|
||||
}
|
||||
|
||||
export const MEMORY_TOOL_ANNOTATIONS = {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: true,
|
||||
idempotentHint: false,
|
||||
openWorldHint: false,
|
||||
} as const
|
||||
}
|
||||
|
||||
export const ADDITIVE_MEMORY_TOOL_ANNOTATIONS = {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
openWorldHint: false,
|
||||
}
|
||||
|
||||
/** Non-destructive account/session mutations, e.g. switching the active space. */
|
||||
export const SETTINGS_TOOL_ANNOTATIONS = {
|
||||
|
|
@ -19,4 +26,4 @@ export const SETTINGS_TOOL_ANNOTATIONS = {
|
|||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
} as const
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { z } from "zod"
|
||||
import { documentsApiResponseSchema } from "../../shared/types"
|
||||
import { appToolMeta } from "../app-metadata"
|
||||
import { optionalContainerTagSchema } from "../container-tag"
|
||||
import { READ_ONLY_TOOL_ANNOTATIONS } from "./annotations"
|
||||
|
|
@ -14,6 +15,7 @@ export function register(deps: ToolDeps) {
|
|||
page: z.number().optional().default(1),
|
||||
limit: z.number().optional().default(200),
|
||||
}),
|
||||
outputSchema: documentsApiResponseSchema,
|
||||
annotations: READ_ONLY_TOOL_ANNOTATIONS,
|
||||
_meta: appToolMeta(["app"]),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { z } from "zod"
|
||||
import type { ViewMessage } from "../../shared/types"
|
||||
import { saveViewSchema, type ViewMessage } from "../../shared/types"
|
||||
import { appResultMeta, appToolMeta } from "../app-metadata"
|
||||
import { effectiveContainerTagAccess } from "../auth/rbac"
|
||||
import { READ_ONLY_TOOL_ANNOTATIONS } from "./annotations"
|
||||
|
|
@ -10,10 +10,12 @@ export function register(deps: ToolDeps) {
|
|||
"guided-save",
|
||||
{
|
||||
title: "Add Memory",
|
||||
description: "Save information to memory with an interactive form.",
|
||||
description:
|
||||
"Open an interactive form when the user wants to draft, review, edit, or choose the target space before saving information to Supermemory. Use this when the user wants to add a memory but has not supplied final content, or explicitly wants to review supplied content before saving. If the user provides the exact content and asks to save it immediately, use add_memory instead.",
|
||||
inputSchema: z.object({
|
||||
prefill: z.string().optional().describe("Optional content to prefill"),
|
||||
}),
|
||||
outputSchema: saveViewSchema,
|
||||
_meta: appToolMeta(),
|
||||
annotations: READ_ONLY_TOOL_ANNOTATIONS,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { z } from "zod"
|
||||
import { listSpacesOutputSchema } from "../../shared/types"
|
||||
import { READ_ONLY_TOOL_ANNOTATIONS } from "./annotations"
|
||||
import type { ToolDeps } from "./types"
|
||||
|
||||
|
|
@ -9,11 +10,22 @@ export function register(deps: ToolDeps) {
|
|||
description:
|
||||
"List the spaces available to the user. Returns each space's name, key, emoji, document/memory counts, and last activity. Use this first to resolve a named space before calling a space-aware tool, or when the user asks which space may contain something. The list is auto-filtered to spaces the user can access.",
|
||||
inputSchema: z.object({}),
|
||||
outputSchema: listSpacesOutputSchema,
|
||||
annotations: READ_ONLY_TOOL_ANNOTATIONS,
|
||||
},
|
||||
async () => {
|
||||
try {
|
||||
const tags = await deps.getClient().listContainerTags()
|
||||
const spaces = tags.map((tag) => ({
|
||||
name: tag.name,
|
||||
containerTag: tag.containerTag,
|
||||
description: tag.description,
|
||||
visibility: tag.visibility,
|
||||
emoji: tag.emoji,
|
||||
documentCount: tag.documentCount,
|
||||
memoryCount: tag.memoryCount,
|
||||
lastActivityAt: tag.lastActivityAt,
|
||||
}))
|
||||
|
||||
if (tags.length === 0) {
|
||||
return {
|
||||
|
|
@ -23,6 +35,7 @@ export function register(deps: ToolDeps) {
|
|||
text: "No spaces found.",
|
||||
},
|
||||
],
|
||||
structuredContent: { spaces, count: 0 },
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -39,7 +52,7 @@ export function register(deps: ToolDeps) {
|
|||
text: `Available spaces:\n${lines.join("\n")}`,
|
||||
},
|
||||
],
|
||||
structuredContent: { containerTags: tags },
|
||||
structuredContent: { spaces, count: spaces.length },
|
||||
}
|
||||
} catch (error) {
|
||||
return deps.errorResult(error)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { z } from "zod"
|
||||
import type { ViewMessage } from "../../shared/types"
|
||||
import { appToolMeta } from "../app-metadata"
|
||||
import { graphViewSchema, type ViewMessage } from "../../shared/types"
|
||||
import { appResultMeta, appToolMeta } from "../app-metadata"
|
||||
import { optionalContainerTagSchema } from "../container-tag"
|
||||
import { READ_ONLY_TOOL_ANNOTATIONS } from "./annotations"
|
||||
import type { ToolDeps } from "./types"
|
||||
|
|
@ -15,13 +15,15 @@ export function register(deps: ToolDeps) {
|
|||
{
|
||||
title: "Memory Graph",
|
||||
description:
|
||||
"Render the space's memory graph directly as an interactive MCP App. This tool is the final visualization; do not create another graph, file, or artifact unless the user explicitly asks for one. When the user names a space, resolve it with listSpaces and pass containerTag.",
|
||||
"Render a space's memory graph directly as an interactive MCP App. This tool is the final visualization; do not create another graph, file, or artifact unless the user explicitly asks for one. If the user names a space, call listSpaces to resolve its key and pass it as containerTag. If the user does not name a space, call this tool directly and omit containerTag; the server uses the active space or account default. Do not open the space picker unless the user asks to change their active space.",
|
||||
inputSchema,
|
||||
outputSchema: graphViewSchema,
|
||||
annotations: READ_ONLY_TOOL_ANNOTATIONS,
|
||||
_meta: appToolMeta(),
|
||||
},
|
||||
async (args) => {
|
||||
try {
|
||||
const viewId = crypto.randomUUID()
|
||||
const effectiveTag = await deps.resolveContainerTag(args.containerTag)
|
||||
const client = deps.getClient(effectiveTag)
|
||||
const containerTags = effectiveTag ? [effectiveTag] : undefined
|
||||
|
|
@ -35,19 +37,27 @@ export function register(deps: ToolDeps) {
|
|||
|
||||
const sc: ViewMessage = {
|
||||
view: "graph",
|
||||
containerTag: effectiveTag,
|
||||
documents: result.documents,
|
||||
totalCount: result.pagination.totalItems,
|
||||
viewId,
|
||||
...(effectiveTag ? { containerTag: effectiveTag } : {}),
|
||||
documentCount: result.documents.length,
|
||||
memoryCount,
|
||||
totalDocumentCount: result.pagination.totalItems,
|
||||
truncated: result.documents.length < result.pagination.totalItems,
|
||||
rendered: true,
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: `Rendered the interactive Memory Graph MCP App: ${result.documents.length} documents, ${memoryCount} memories${effectiveTag ? `. Space: ${effectiveTag}` : ""}. Do not create a duplicate graph or artifact unless the user explicitly requests one.`,
|
||||
text: `The interactive Memory Graph MCP App is rendered and visible: ${result.documents.length} documents, ${memoryCount} memories${effectiveTag ? `. Space: ${effectiveTag}` : ""}. Do not create a duplicate graph or artifact unless the user explicitly requests one.`,
|
||||
},
|
||||
],
|
||||
structuredContent: sc,
|
||||
_meta: {
|
||||
...appResultMeta(viewId),
|
||||
graphData: { documents: result.documents },
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
return deps.errorResult(error)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { z } from "zod"
|
||||
import type { ViewMessage } from "../../shared/types"
|
||||
import { saveSuccessViewSchema, type ViewMessage } from "../../shared/types"
|
||||
import { appResultMeta, appToolMeta } from "../app-metadata"
|
||||
import { containerTagSchema } from "../container-tag"
|
||||
import { MEMORY_TOOL_ANNOTATIONS } from "./annotations"
|
||||
import { ADDITIVE_MEMORY_TOOL_ANNOTATIONS } from "./annotations"
|
||||
import type { ToolDeps } from "./types"
|
||||
|
||||
export function register(deps: ToolDeps) {
|
||||
|
|
@ -15,7 +15,8 @@ export function register(deps: ToolDeps) {
|
|||
containerTag: containerTagSchema,
|
||||
viewId: z.string().uuid().optional(),
|
||||
}),
|
||||
annotations: MEMORY_TOOL_ANNOTATIONS,
|
||||
outputSchema: saveSuccessViewSchema,
|
||||
annotations: ADDITIVE_MEMORY_TOOL_ANNOTATIONS,
|
||||
_meta: appToolMeta(["app"]),
|
||||
},
|
||||
async (args) => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { z } from "zod"
|
||||
import type { ViewMessage } from "../../shared/types"
|
||||
import { pickerViewSchema, type ViewMessage } from "../../shared/types"
|
||||
import { appResultMeta, appToolMeta } from "../app-metadata"
|
||||
import { effectiveContainerTagAccess } from "../auth/rbac"
|
||||
import { READ_ONLY_TOOL_ANNOTATIONS } from "./annotations"
|
||||
|
|
@ -11,8 +11,9 @@ export function register(deps: ToolDeps) {
|
|||
{
|
||||
title: "Select Space",
|
||||
description:
|
||||
"Choose the active Supermemory space. Shows available spaces as interactive cards.",
|
||||
"Open an interactive picker to choose or change the active Supermemory space used for future actions. Use this only when the user asks to switch, select, or change their active or default space. Do not use it merely because the user names a space for a search, list, graph, save, or upload; resolve that space with listSpaces and pass containerTag to the relevant tool instead.",
|
||||
inputSchema: z.object({}),
|
||||
outputSchema: pickerViewSchema,
|
||||
_meta: appToolMeta(),
|
||||
annotations: READ_ONLY_TOOL_ANNOTATIONS,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { z } from "zod"
|
||||
import type { ViewMessage } from "../../shared/types"
|
||||
import { confirmationViewSchema, type ViewMessage } from "../../shared/types"
|
||||
import { appResultMeta, appToolMeta } from "../app-metadata"
|
||||
import { containerTagSchema } from "../container-tag"
|
||||
import { SETTINGS_TOOL_ANNOTATIONS } from "./annotations"
|
||||
|
|
@ -14,6 +14,7 @@ export function register(deps: ToolDeps) {
|
|||
containerTag: containerTagSchema,
|
||||
viewId: z.string().uuid().optional(),
|
||||
}),
|
||||
outputSchema: confirmationViewSchema,
|
||||
_meta: appToolMeta(["app"]),
|
||||
annotations: SETTINGS_TOOL_ANNOTATIONS,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { z } from "zod"
|
||||
import type { ViewMessage } from "../../shared/types"
|
||||
import { uploadSuccessViewSchema, type ViewMessage } from "../../shared/types"
|
||||
import { appResultMeta, appToolMeta } from "../app-metadata"
|
||||
import { containerTagSchema } from "../container-tag"
|
||||
import { MEMORY_TOOL_ANNOTATIONS } from "./annotations"
|
||||
import { ADDITIVE_MEMORY_TOOL_ANNOTATIONS } from "./annotations"
|
||||
import type { ToolDeps } from "./types"
|
||||
|
||||
export function register(deps: ToolDeps) {
|
||||
|
|
@ -17,7 +17,8 @@ export function register(deps: ToolDeps) {
|
|||
containerTag: containerTagSchema,
|
||||
viewId: z.string().uuid().optional(),
|
||||
}),
|
||||
annotations: MEMORY_TOOL_ANNOTATIONS,
|
||||
outputSchema: uploadSuccessViewSchema,
|
||||
annotations: ADDITIVE_MEMORY_TOOL_ANNOTATIONS,
|
||||
_meta: appToolMeta(["app"]),
|
||||
},
|
||||
async (args) => {
|
||||
|
|
@ -31,7 +32,7 @@ export function register(deps: ToolDeps) {
|
|||
|
||||
const client = deps.getClient(args.containerTag)
|
||||
const result = await client.uploadFile(
|
||||
bytes.buffer as ArrayBuffer,
|
||||
bytes.buffer,
|
||||
args.fileName,
|
||||
args.mimeType,
|
||||
args.containerTag,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { z } from "zod"
|
||||
import type { ViewMessage } from "../../shared/types"
|
||||
import { uploadViewSchema, type ViewMessage } from "../../shared/types"
|
||||
import { appResultMeta, appToolMeta } from "../app-metadata"
|
||||
import { effectiveContainerTagAccess } from "../auth/rbac"
|
||||
import { READ_ONLY_TOOL_ANNOTATIONS } from "./annotations"
|
||||
|
|
@ -10,8 +10,10 @@ export function register(deps: ToolDeps) {
|
|||
"upload-file",
|
||||
{
|
||||
title: "Upload File",
|
||||
description: "Upload a file (PDF, text, image, video) to memory.",
|
||||
description:
|
||||
"Open Supermemory's interactive file picker whenever the user wants to upload, import, or add any local file to Supermemory. Call this tool immediately even when the user only says they want to upload a file. Do not ask for a file path, folder, filename, or filesystem access; the picker handles file selection. It supports documents, text, spreadsheets, images, audio, and video.",
|
||||
inputSchema: z.object({}),
|
||||
outputSchema: uploadViewSchema,
|
||||
_meta: appToolMeta(),
|
||||
annotations: READ_ONLY_TOOL_ANNOTATIONS,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ export function register(deps: ToolDeps) {
|
|||
deps.server.registerTool(
|
||||
"whoAmI",
|
||||
{
|
||||
description: "Get current user info, role, and space context",
|
||||
description:
|
||||
"Get the current Supermemory account context, including user identity, role, access type, permissions, scope, and active space. Use this when the user asks who they are, what access they have, or which space is currently active. Use listSpaces instead when the user asks which spaces are available.",
|
||||
inputSchema: z.object({}),
|
||||
annotations: READ_ONLY_TOOL_ANNOTATIONS,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,131 +1,200 @@
|
|||
import { z } from "zod"
|
||||
|
||||
// Shared types — imported by both server tools and widget views.
|
||||
// Single source of truth for the server↔widget contract.
|
||||
|
||||
export interface ContainerTagAccess {
|
||||
containerTag: string
|
||||
permission: "read" | "write"
|
||||
}
|
||||
export const containerTagAccessSchema = z.object({
|
||||
containerTag: z.string(),
|
||||
permission: z.enum(["read", "write"]),
|
||||
})
|
||||
|
||||
export interface SessionScope {
|
||||
type: "full" | "scoped"
|
||||
permission?: "read" | "write"
|
||||
tag?: string
|
||||
tags?: string[]
|
||||
rateLimit?: number
|
||||
expires?: string
|
||||
}
|
||||
export type ContainerTagAccess = z.infer<typeof containerTagAccessSchema>
|
||||
|
||||
export interface SessionInfo {
|
||||
user: {
|
||||
id: string
|
||||
email?: string
|
||||
name?: string
|
||||
}
|
||||
role?: string
|
||||
accessType?: "full" | "restricted"
|
||||
containerTags?: ContainerTagAccess[] | null
|
||||
scope?: SessionScope
|
||||
}
|
||||
export const sessionScopeSchema = z.looseObject({
|
||||
type: z.enum(["full", "scoped"]),
|
||||
permission: z.enum(["read", "write"]).optional(),
|
||||
tag: z.string().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
rateLimit: z.number().optional(),
|
||||
expires: z.string().optional(),
|
||||
})
|
||||
|
||||
export interface ContainerTag {
|
||||
id: string
|
||||
name: string
|
||||
containerTag: string
|
||||
description?: string | null
|
||||
visibility?: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
isExperimental: boolean
|
||||
emoji?: string
|
||||
isNova: boolean
|
||||
documentCount: number
|
||||
memoryCount: number
|
||||
lastActivityAt: string | null
|
||||
}
|
||||
export type SessionScope = z.infer<typeof sessionScopeSchema>
|
||||
|
||||
export interface DocumentMemoryEntry {
|
||||
id: string
|
||||
memory: string
|
||||
spaceId: string
|
||||
isStatic?: boolean
|
||||
isLatest?: boolean
|
||||
isForgotten?: boolean
|
||||
forgetAfter?: string | null
|
||||
forgetReason?: string | null
|
||||
version?: number
|
||||
parentMemoryId?: string | null
|
||||
rootMemoryId?: string | null
|
||||
memoryRelations?: Record<string, string>
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
export const sessionInfoSchema = z.looseObject({
|
||||
user: z.looseObject({
|
||||
id: z.string().min(1),
|
||||
email: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
}),
|
||||
role: z.string().optional(),
|
||||
accessType: z.enum(["full", "restricted"]).optional(),
|
||||
containerTags: z.array(containerTagAccessSchema).nullable().optional(),
|
||||
scope: sessionScopeSchema.optional(),
|
||||
})
|
||||
|
||||
export interface DocumentWithMemories {
|
||||
id: string
|
||||
title: string | null
|
||||
summary?: string | null
|
||||
type: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
memoryEntries: DocumentMemoryEntry[]
|
||||
}
|
||||
export type SessionInfo = z.infer<typeof sessionInfoSchema>
|
||||
|
||||
export interface DocumentsApiResponse {
|
||||
documents: DocumentWithMemories[]
|
||||
pagination: {
|
||||
currentPage: number
|
||||
limit: number
|
||||
totalItems: number
|
||||
totalPages: number
|
||||
}
|
||||
}
|
||||
export const containerTagSchema = z.looseObject({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
containerTag: z.string(),
|
||||
description: z.string().nullish(),
|
||||
visibility: z.string().nullish(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
isExperimental: z.boolean(),
|
||||
emoji: z.string().nullish(),
|
||||
isNova: z.boolean(),
|
||||
documentCount: z.number().int().nonnegative(),
|
||||
memoryCount: z.number().int().nonnegative(),
|
||||
lastActivityAt: z.string().nullish(),
|
||||
})
|
||||
|
||||
export type ContainerTag = z.infer<typeof containerTagSchema>
|
||||
|
||||
export const spaceSummarySchema = z.object({
|
||||
name: z.string(),
|
||||
containerTag: z.string(),
|
||||
description: z.string().nullish(),
|
||||
visibility: z.string().nullish(),
|
||||
emoji: z.string().nullish(),
|
||||
documentCount: z.number().int().nonnegative(),
|
||||
memoryCount: z.number().int().nonnegative(),
|
||||
lastActivityAt: z.string().nullish(),
|
||||
})
|
||||
|
||||
export const listSpacesOutputSchema = z.object({
|
||||
spaces: z.array(spaceSummarySchema),
|
||||
count: z.number().int().nonnegative(),
|
||||
})
|
||||
|
||||
export const memoryRelationSchema = z.enum(["updates", "extends", "derives"])
|
||||
|
||||
export const documentMemoryEntrySchema = z.looseObject({
|
||||
id: z.string(),
|
||||
memory: z.string(),
|
||||
spaceId: z.string(),
|
||||
isStatic: z.boolean().nullish(),
|
||||
isLatest: z.boolean().nullish(),
|
||||
isForgotten: z.boolean().nullish(),
|
||||
forgetAfter: z.string().nullish(),
|
||||
forgetReason: z.string().nullish(),
|
||||
version: z.number().nullish(),
|
||||
parentMemoryId: z.string().nullish(),
|
||||
rootMemoryId: z.string().nullish(),
|
||||
memoryRelations: z.record(z.string(), memoryRelationSchema).nullish(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
})
|
||||
|
||||
export type DocumentMemoryEntry = z.infer<typeof documentMemoryEntrySchema>
|
||||
|
||||
export const documentWithMemoriesSchema = z.looseObject({
|
||||
id: z.string(),
|
||||
title: z.string().nullable(),
|
||||
summary: z.string().nullish(),
|
||||
type: z.string(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
memoryEntries: z.array(documentMemoryEntrySchema),
|
||||
})
|
||||
|
||||
export type DocumentWithMemories = z.infer<typeof documentWithMemoriesSchema>
|
||||
|
||||
export const paginationSchema = z.object({
|
||||
currentPage: z.number().int().nonnegative(),
|
||||
limit: z.number().int().nonnegative(),
|
||||
totalItems: z.number().int().nonnegative(),
|
||||
totalPages: z.number().int().nonnegative(),
|
||||
})
|
||||
|
||||
export const documentsApiResponseSchema = z.object({
|
||||
documents: z.array(documentWithMemoriesSchema),
|
||||
pagination: paginationSchema,
|
||||
})
|
||||
|
||||
export type DocumentsApiResponse = z.infer<typeof documentsApiResponseSchema>
|
||||
|
||||
// ViewMessage — discriminated union returned by app tools as `structuredContent`.
|
||||
// The widget uses an exhaustive switch on `view` to dispatch to the correct view component.
|
||||
// Adding a new view here is a compile error in App.tsx until the case is handled.
|
||||
type ViewMessagePayload =
|
||||
| {
|
||||
view: "picker"
|
||||
containerTags: ContainerTag[]
|
||||
activeTag?: string | null
|
||||
assignedTags?: ContainerTagAccess[] | null
|
||||
}
|
||||
| { view: "confirmation"; containerTag: string }
|
||||
| {
|
||||
view: "save"
|
||||
activeTag?: string | null
|
||||
writableTags: string[]
|
||||
prefill?: string
|
||||
}
|
||||
| { view: "save-success"; id: string; containerTag: string }
|
||||
| {
|
||||
view: "upload"
|
||||
activeTag?: string | null
|
||||
writableTags: string[]
|
||||
}
|
||||
| {
|
||||
view: "upload-success"
|
||||
id: string
|
||||
fileName: string
|
||||
containerTag: string
|
||||
}
|
||||
| {
|
||||
view: "graph"
|
||||
documents: DocumentWithMemories[]
|
||||
totalCount: number
|
||||
containerTag?: string
|
||||
}
|
||||
const viewIdSchema = z.string().uuid().optional()
|
||||
|
||||
export type ViewMessage = ViewMessagePayload & {
|
||||
/**
|
||||
* Stable identity for one rendered widget instance.
|
||||
*
|
||||
* The host may remount the iframe when a conversation is revisited. The
|
||||
* widget uses this id to restore a completed local view without treating UI
|
||||
* state as the source of truth for the underlying Supermemory write.
|
||||
*/
|
||||
viewId?: string
|
||||
}
|
||||
export const pickerViewSchema = z.object({
|
||||
view: z.literal("picker"),
|
||||
viewId: viewIdSchema,
|
||||
containerTags: z.array(containerTagSchema),
|
||||
activeTag: z.string().nullish(),
|
||||
assignedTags: z.array(containerTagAccessSchema).nullable().optional(),
|
||||
})
|
||||
|
||||
export const confirmationViewSchema = z.object({
|
||||
view: z.literal("confirmation"),
|
||||
viewId: viewIdSchema,
|
||||
containerTag: z.string(),
|
||||
})
|
||||
|
||||
export const saveViewSchema = z.object({
|
||||
view: z.literal("save"),
|
||||
viewId: viewIdSchema,
|
||||
activeTag: z.string().nullish(),
|
||||
writableTags: z.array(z.string()),
|
||||
prefill: z.string().optional(),
|
||||
})
|
||||
|
||||
export const saveSuccessViewSchema = z.object({
|
||||
view: z.literal("save-success"),
|
||||
viewId: viewIdSchema,
|
||||
id: z.string(),
|
||||
containerTag: z.string(),
|
||||
})
|
||||
|
||||
export const uploadViewSchema = z.object({
|
||||
view: z.literal("upload"),
|
||||
viewId: viewIdSchema,
|
||||
activeTag: z.string().nullish(),
|
||||
writableTags: z.array(z.string()),
|
||||
})
|
||||
|
||||
export const uploadSuccessViewSchema = z.object({
|
||||
view: z.literal("upload-success"),
|
||||
viewId: viewIdSchema,
|
||||
id: z.string(),
|
||||
fileName: z.string(),
|
||||
containerTag: z.string(),
|
||||
})
|
||||
|
||||
export const graphViewSchema = z.object({
|
||||
view: z.literal("graph"),
|
||||
viewId: viewIdSchema,
|
||||
containerTag: z.string().optional(),
|
||||
documentCount: z.number().int().nonnegative(),
|
||||
memoryCount: z.number().int().nonnegative(),
|
||||
totalDocumentCount: z.number().int().nonnegative(),
|
||||
truncated: z.boolean(),
|
||||
rendered: z.literal(true),
|
||||
})
|
||||
|
||||
export const viewMessageSchema = z.discriminatedUnion("view", [
|
||||
pickerViewSchema,
|
||||
confirmationViewSchema,
|
||||
saveViewSchema,
|
||||
saveSuccessViewSchema,
|
||||
uploadViewSchema,
|
||||
uploadSuccessViewSchema,
|
||||
graphViewSchema,
|
||||
])
|
||||
|
||||
export type ViewMessage = z.infer<typeof viewMessageSchema>
|
||||
|
||||
export const graphResultMetaSchema = z.looseObject({
|
||||
graphData: z.object({
|
||||
documents: z.array(documentWithMemoriesSchema),
|
||||
}),
|
||||
})
|
||||
|
||||
export type GraphResultMeta = z.infer<typeof graphResultMetaSchema>
|
||||
|
||||
export type ViewName = ViewMessage["view"]
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { type ReactNode, useEffect } from "react"
|
||||
import type { ViewMessage } from "../shared/types"
|
||||
import type { GraphResultMeta, ViewMessage } from "../shared/types"
|
||||
import { useApplyHostTheme } from "./hooks/useApplyHostTheme"
|
||||
import { useLog } from "./hooks/useLog"
|
||||
import { useViewState } from "./hooks/useViewState"
|
||||
|
|
@ -50,7 +50,7 @@ export function App() {
|
|||
const isGraphView = state.message.view === "graph"
|
||||
return (
|
||||
<WidgetShell immersive={isGraphView}>
|
||||
{renderView(state.message, setView, setError)}
|
||||
{renderView(state.message, state.resultMeta, setView, setError)}
|
||||
</WidgetShell>
|
||||
)
|
||||
}
|
||||
|
|
@ -93,6 +93,7 @@ export function WidgetShell({
|
|||
|
||||
function renderView(
|
||||
msg: ViewMessage,
|
||||
resultMeta: GraphResultMeta | undefined,
|
||||
setView: (m: ViewMessage) => void,
|
||||
setError: (m: string) => void,
|
||||
) {
|
||||
|
|
@ -130,11 +131,14 @@ function renderView(
|
|||
/>
|
||||
)
|
||||
case "graph":
|
||||
if (!resultMeta) {
|
||||
return <ErrorView message="Memory graph data is unavailable." />
|
||||
}
|
||||
return (
|
||||
<Graph
|
||||
containerTag={msg.containerTag}
|
||||
documents={msg.documents}
|
||||
totalCount={msg.totalCount}
|
||||
documents={resultMeta.graphData.documents}
|
||||
totalCount={msg.totalDocumentCount}
|
||||
/>
|
||||
)
|
||||
case "confirmation":
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import type {
|
|||
McpUiHostContext,
|
||||
} from "@modelcontextprotocol/ext-apps"
|
||||
import { useApp as useMcpApp } from "@modelcontextprotocol/ext-apps/react"
|
||||
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
|
|
@ -11,12 +10,17 @@ import {
|
|||
useMemo,
|
||||
useState,
|
||||
} from "react"
|
||||
import type { ViewMessage } from "../shared/types"
|
||||
import {
|
||||
graphResultMetaSchema,
|
||||
type GraphResultMeta,
|
||||
viewMessageSchema,
|
||||
type ViewMessage,
|
||||
} from "../shared/types"
|
||||
import { loadViewCheckpoint, saveViewCheckpoint } from "./lib/viewCheckpoint"
|
||||
|
||||
export type ViewState =
|
||||
| { kind: "loading" }
|
||||
| { kind: "view"; message: ViewMessage }
|
||||
| { kind: "view"; message: ViewMessage; resultMeta?: GraphResultMeta }
|
||||
| { kind: "error"; message: string }
|
||||
| { kind: "raw"; structuredContent: unknown }
|
||||
|
||||
|
|
@ -61,12 +65,8 @@ export function McpAppProvider({ children }: { children: ReactNode }) {
|
|||
capabilities: {},
|
||||
strict: true,
|
||||
onAppCreated: (createdApp) => {
|
||||
createdApp.ontoolinput = (input: unknown) => {
|
||||
const name =
|
||||
typeof input === "object" && input !== null && "name" in input
|
||||
? String((input as { name: unknown }).name)
|
||||
: "?"
|
||||
safeLog(createdApp, "info", `[host] ontoolinput: ${name}`)
|
||||
createdApp.ontoolinput = () => {
|
||||
safeLog(createdApp, "info", "[host] ontoolinput")
|
||||
setState({ kind: "loading" })
|
||||
}
|
||||
createdApp.ontoolinputpartial = () => setState({ kind: "loading" })
|
||||
|
|
@ -74,35 +74,45 @@ export function McpAppProvider({ children }: { children: ReactNode }) {
|
|||
safeLog(createdApp, "info", "[host] ontoolcancelled")
|
||||
setState({ kind: "loading" })
|
||||
}
|
||||
createdApp.ontoolresult = (result: CallToolResult) => {
|
||||
const structuredContent = (result as { structuredContent?: unknown })
|
||||
.structuredContent
|
||||
if (!structuredContent || typeof structuredContent !== "object") {
|
||||
createdApp.ontoolresult = (result) => {
|
||||
const structuredContent = result.structuredContent
|
||||
const parsedMessage = viewMessageSchema.safeParse(structuredContent)
|
||||
if (!parsedMessage.success) {
|
||||
safeLog(
|
||||
createdApp,
|
||||
"warning",
|
||||
"[host] ontoolresult: no structuredContent",
|
||||
"[host] ontoolresult: invalid structuredContent",
|
||||
)
|
||||
setState({ kind: "raw", structuredContent })
|
||||
return
|
||||
}
|
||||
if ("view" in structuredContent) {
|
||||
const message = structuredContent as ViewMessage
|
||||
safeLog(
|
||||
createdApp,
|
||||
"info",
|
||||
`[host] ontoolresult: view=${message.view}`,
|
||||
)
|
||||
const checkpoint = loadViewCheckpoint(message.viewId)
|
||||
setState({ kind: "view", message: checkpoint ?? message })
|
||||
|
||||
const message = parsedMessage.data
|
||||
safeLog(createdApp, "info", `[host] ontoolresult: view=${message.view}`)
|
||||
const checkpoint = loadViewCheckpoint(message.viewId)
|
||||
if (checkpoint) {
|
||||
setState({ kind: "view", message: checkpoint })
|
||||
return
|
||||
}
|
||||
safeLog(
|
||||
createdApp,
|
||||
"warning",
|
||||
"[host] ontoolresult: structuredContent without view",
|
||||
)
|
||||
setState({ kind: "raw", structuredContent })
|
||||
|
||||
if (message.view === "graph") {
|
||||
const parsedMeta = graphResultMetaSchema.safeParse(result._meta)
|
||||
if (!parsedMeta.success) {
|
||||
setState({
|
||||
kind: "error",
|
||||
message: "Memory graph data is unavailable.",
|
||||
})
|
||||
return
|
||||
}
|
||||
setState({
|
||||
kind: "view",
|
||||
message,
|
||||
resultMeta: parsedMeta.data,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setState({ kind: "view", message })
|
||||
}
|
||||
createdApp.onhostcontextchanged = (next) => {
|
||||
setHostContext(createdApp.getHostContext() ?? next)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,5 @@
|
|||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import {
|
||||
type ButtonHTMLAttributes,
|
||||
forwardRef,
|
||||
type HTMLAttributes,
|
||||
} from "react"
|
||||
import { forwardRef, type HTMLAttributes } from "react"
|
||||
import { cn } from "../lib/cn"
|
||||
|
||||
const cardStyles = cva(
|
||||
|
|
@ -28,38 +24,15 @@ const cardStyles = cva(
|
|||
|
||||
type CardVariantProps = VariantProps<typeof cardStyles>
|
||||
|
||||
type DivProps = HTMLAttributes<HTMLDivElement> &
|
||||
CardVariantProps & { as?: "div" }
|
||||
export type CardProps = HTMLAttributes<HTMLDivElement> & CardVariantProps
|
||||
|
||||
type ButtonElementProps = ButtonHTMLAttributes<HTMLButtonElement> &
|
||||
CardVariantProps & { as: "button" }
|
||||
|
||||
export type CardProps = DivProps | ButtonElementProps
|
||||
|
||||
export const Card = forwardRef<HTMLElement, CardProps>(
|
||||
({ className, variant, as = "div", children, ...props }, ref) => {
|
||||
const cls = cn(cardStyles({ variant }), className)
|
||||
if (as === "button") {
|
||||
return (
|
||||
<button
|
||||
className={cls}
|
||||
ref={ref as React.Ref<HTMLButtonElement>}
|
||||
type="button"
|
||||
{...(props as ButtonHTMLAttributes<HTMLButtonElement>)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={cls}
|
||||
ref={ref as React.Ref<HTMLDivElement>}
|
||||
{...(props as HTMLAttributes<HTMLDivElement>)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
export const Card = forwardRef<HTMLDivElement, CardProps>(
|
||||
({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
className={cn(cardStyles({ variant }), className)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
)
|
||||
Card.displayName = "Card"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"
|
||||
import { useContext, useMemo } from "react"
|
||||
import type { ViewMessage } from "../../shared/types"
|
||||
import { viewMessageSchema, type ViewMessage } from "../../shared/types"
|
||||
import {
|
||||
handoffToModel as performModelHandoff,
|
||||
type ModelHandoffRequest,
|
||||
|
|
@ -32,18 +31,18 @@ export function useApp() {
|
|||
return useMemo(() => {
|
||||
return {
|
||||
/** Call an MCP server tool and await the result. */
|
||||
async callTool<T = ViewMessage>(
|
||||
async callTool(
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
): Promise<ToolCallResult<T>> {
|
||||
): Promise<ToolCallResult<ViewMessage>> {
|
||||
if (!app) {
|
||||
return { ok: false, error: "MCP host is not connected" }
|
||||
}
|
||||
try {
|
||||
const result = (await app.callServerTool({
|
||||
const result = await app.callServerTool({
|
||||
name,
|
||||
arguments: args,
|
||||
})) as CallToolResult & { structuredContent?: unknown }
|
||||
})
|
||||
if (result.isError) {
|
||||
const text =
|
||||
result.content?.[0]?.type === "text"
|
||||
|
|
@ -51,9 +50,16 @@ export function useApp() {
|
|||
: "Tool returned an error"
|
||||
return { ok: false, error: text }
|
||||
}
|
||||
const parsed = viewMessageSchema.safeParse(result.structuredContent)
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Tool returned invalid structured content",
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
data: result.structuredContent as T,
|
||||
data: parsed.data,
|
||||
}
|
||||
} catch (err) {
|
||||
return { ok: false, error: String(err) }
|
||||
|
|
|
|||
|
|
@ -8,36 +8,21 @@ import { useEffect } from "react"
|
|||
import { useHostContext } from "./useHostContext"
|
||||
|
||||
function applyHostDimensions(ctx: McpUiHostContext) {
|
||||
const dims = (
|
||||
ctx as McpUiHostContext & {
|
||||
containerDimensions?: { height?: number; width?: number }
|
||||
}
|
||||
).containerDimensions
|
||||
if (dims?.height) {
|
||||
document.documentElement.style.setProperty(
|
||||
"--host-height",
|
||||
`${dims.height}px`,
|
||||
)
|
||||
const dimensions = ctx.containerDimensions
|
||||
const height =
|
||||
dimensions && "height" in dimensions ? dimensions.height : undefined
|
||||
const width =
|
||||
dimensions && "width" in dimensions ? dimensions.width : undefined
|
||||
if (height) {
|
||||
document.documentElement.style.setProperty("--host-height", `${height}px`)
|
||||
}
|
||||
if (dims?.width) {
|
||||
document.documentElement.style.setProperty(
|
||||
"--host-width",
|
||||
`${dims.width}px`,
|
||||
)
|
||||
if (width) {
|
||||
document.documentElement.style.setProperty("--host-width", `${width}px`)
|
||||
}
|
||||
}
|
||||
|
||||
function applySafeArea(ctx: McpUiHostContext) {
|
||||
const insets = (
|
||||
ctx as McpUiHostContext & {
|
||||
safeAreaInsets?: {
|
||||
top: number
|
||||
right: number
|
||||
bottom: number
|
||||
left: number
|
||||
}
|
||||
}
|
||||
).safeAreaInsets
|
||||
const insets = ctx.safeAreaInsets
|
||||
if (insets) {
|
||||
const { top, right, bottom, left } = insets
|
||||
document.body.style.padding = `${top}px ${right}px ${bottom}px ${left}px`
|
||||
|
|
|
|||
|
|
@ -7,6 +7,12 @@ export interface ChatGptHostApi {
|
|||
widgetState?: unknown
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
openai?: ChatGptHostApi
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional ChatGPT host extensions. Shared MCP Apps methods remain the
|
||||
* cross-host fallback; this bridge is used where ChatGPT provides a stronger
|
||||
|
|
@ -14,5 +20,5 @@ export interface ChatGptHostApi {
|
|||
*/
|
||||
export function getChatGptHostApi(): ChatGptHostApi | undefined {
|
||||
if (typeof window === "undefined") return undefined
|
||||
return (window as Window & { openai?: ChatGptHostApi }).openai
|
||||
return window.openai
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,11 @@ export function readFileAsBase64(file: File): Promise<string> {
|
|||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
const result = reader.result as string
|
||||
if (typeof reader.result !== "string") {
|
||||
reject(new Error("Unable to read file as base64"))
|
||||
return
|
||||
}
|
||||
const result = reader.result
|
||||
const comma = result.indexOf(",")
|
||||
resolve(comma >= 0 ? result.slice(comma + 1) : result)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
import type { ViewMessage, ViewName } from "../../shared/types"
|
||||
import {
|
||||
viewMessageSchema,
|
||||
type ViewMessage,
|
||||
type ViewName,
|
||||
} from "../../shared/types"
|
||||
import { getChatGptHostApi } from "./openaiHost"
|
||||
|
||||
const CHECKPOINT_VERSION = 1
|
||||
|
|
@ -8,15 +12,6 @@ const CHECKPOINTABLE_VIEWS = new Set<ViewName>([
|
|||
"save-success",
|
||||
"upload-success",
|
||||
])
|
||||
const VIEW_NAMES = new Set<ViewName>([
|
||||
"picker",
|
||||
"confirmation",
|
||||
"save",
|
||||
"save-success",
|
||||
"upload",
|
||||
"upload-success",
|
||||
"graph",
|
||||
])
|
||||
|
||||
interface CheckpointEnvelope {
|
||||
version: number
|
||||
|
|
@ -28,9 +23,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||
}
|
||||
|
||||
function isViewMessage(value: unknown): value is ViewMessage {
|
||||
if (!isRecord(value) || typeof value.view !== "string") return false
|
||||
if (!VIEW_NAMES.has(value.view as ViewName)) return false
|
||||
return value.viewId === undefined || typeof value.viewId === "string"
|
||||
return viewMessageSchema.safeParse(value).success
|
||||
}
|
||||
|
||||
function checkpointKey(viewId: string): string {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ import { ErrorBoundary } from "./ErrorBoundary"
|
|||
import { McpAppProvider } from "./McpAppProvider"
|
||||
import "./design/globals.css"
|
||||
|
||||
const root = createRoot(document.getElementById("app") as HTMLElement)
|
||||
const rootElement = document.getElementById("app")
|
||||
if (!rootElement) throw new Error("Missing app root")
|
||||
const root = createRoot(rootElement)
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<McpAppProvider>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,9 @@ import "../design/globals.css"
|
|||
// primitive and view with mock data. Run with `bun run studio`.
|
||||
document.documentElement.setAttribute("data-theme", "light")
|
||||
|
||||
const root = createRoot(document.getElementById("studio") as HTMLElement)
|
||||
const rootElement = document.getElementById("studio")
|
||||
if (!rootElement) throw new Error("Missing studio root")
|
||||
const root = createRoot(rootElement)
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<McpAppPreviewProvider>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import {
|
|||
type GraphApiMemory,
|
||||
type GraphThemeColors,
|
||||
MemoryGraph,
|
||||
type MemoryRelation,
|
||||
} from "@supermemory/memory-graph"
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import type {
|
||||
|
|
@ -87,8 +86,6 @@ interface Props {
|
|||
containerTag?: string
|
||||
}
|
||||
|
||||
type DisplayMode = "inline" | "fullscreen" | "pip"
|
||||
|
||||
// Map the widget's API shape (DocumentWithMemories) into the package's
|
||||
// GraphApiDocument shape. Mirrors console-v2's use-graph-api transform so the
|
||||
// graph renders identically to the console.
|
||||
|
|
@ -110,9 +107,7 @@ function toGraphMemory(mem: DocumentMemoryEntry): GraphApiMemory {
|
|||
relation: null,
|
||||
updatesMemoryId: null,
|
||||
nextVersionId: null,
|
||||
memoryRelations:
|
||||
(mem.memoryRelations as Record<string, MemoryRelation> | undefined) ??
|
||||
null,
|
||||
memoryRelations: mem.memoryRelations ?? null,
|
||||
spaceContainerTag: null,
|
||||
}
|
||||
}
|
||||
|
|
@ -143,8 +138,8 @@ export function Graph({ documents, totalCount }: Props) {
|
|||
// back via context, so an optimistic local toggle is the source of truth;
|
||||
// we only adopt the host's value when it actually CHANGES (host-initiated
|
||||
// exit, e.g. ESC at the host level). Without this the button is one-way.
|
||||
const ctxMode = ctx?.displayMode as DisplayMode | undefined
|
||||
const [mode, setMode] = useState<DisplayMode>(ctxMode ?? "inline")
|
||||
const ctxMode = ctx?.displayMode
|
||||
const [mode, setMode] = useState(ctxMode ?? "inline")
|
||||
const prevCtxMode = useRef(ctxMode)
|
||||
useEffect(() => {
|
||||
if (ctxMode !== prevCtxMode.current) {
|
||||
|
|
@ -155,7 +150,7 @@ export function Graph({ documents, totalCount }: Props) {
|
|||
|
||||
// Host theme (light/dark). Drives the graph palette reactively so a
|
||||
// mid-session theme switch re-themes the canvas.
|
||||
const theme = (ctx?.theme as string | undefined) ?? "light"
|
||||
const theme = ctx?.theme ?? "light"
|
||||
const colors = useGraphColors(theme)
|
||||
const graphColors = useMemo<Partial<GraphThemeColors>>(
|
||||
() => ({
|
||||
|
|
@ -167,9 +162,7 @@ export function Graph({ documents, totalCount }: Props) {
|
|||
)
|
||||
|
||||
const fullscreenSupported = useMemo(() => {
|
||||
const modes = (
|
||||
ctx as { availableDisplayModes?: string[] } | null | undefined
|
||||
)?.availableDisplayModes
|
||||
const modes = ctx?.availableDisplayModes
|
||||
return Array.isArray(modes) ? modes.includes("fullscreen") : true
|
||||
}, [ctx])
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,16 @@
|
|||
import type { CSSProperties } from "react"
|
||||
|
||||
const loaderStyle: CSSProperties & Record<"--super-loader-size", string> = {
|
||||
"--super-loader-size": "42px",
|
||||
}
|
||||
|
||||
export function Loading() {
|
||||
return (
|
||||
<div className="mcp-widget-loading flex items-center justify-center py-(--space-12)">
|
||||
<output
|
||||
aria-label="Loading..."
|
||||
className="super-loader"
|
||||
style={{ ["--super-loader-size" as string]: "42px" }}
|
||||
style={loaderStyle}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ export function Picker({
|
|||
const handleSelect = async (containerTag: string) => {
|
||||
log("info", `[picker] select: ${containerTag}`)
|
||||
setPending(containerTag)
|
||||
const result = await callTool<ViewMessage>("set-active-tag", {
|
||||
const result = await callTool("set-active-tag", {
|
||||
containerTag,
|
||||
viewId,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ export function Save({
|
|||
if (!canSave || !selectedTag) return
|
||||
log("info", `[save] submit (${trimmed.length} chars → ${selectedTag})`)
|
||||
setSaving(true)
|
||||
const result = await callTool<ViewMessage>("save-memory", {
|
||||
const result = await callTool("save-memory", {
|
||||
content: trimmed,
|
||||
containerTag: selectedTag,
|
||||
viewId,
|
||||
|
|
|
|||
|
|
@ -29,7 +29,8 @@ function formatFileSize(bytes: number): string {
|
|||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
const ACCEPT = ".txt,.pdf,.png,.jpg,.jpeg,.mp4"
|
||||
const ACCEPT =
|
||||
".txt,.md,.pdf,.doc,.docx,.csv,.png,.jpg,.jpeg,.gif,.webp,.mp3,.wav,.m4a,.mp4,.webm"
|
||||
|
||||
export function Upload({
|
||||
activeTag,
|
||||
|
|
@ -64,7 +65,7 @@ export function Upload({
|
|||
setUploading(true)
|
||||
try {
|
||||
const fileData = await readFileAsBase64(file)
|
||||
const result = await callTool<ViewMessage>("upload-file-submit", {
|
||||
const result = await callTool("upload-file-submit", {
|
||||
fileData,
|
||||
fileName: file.name,
|
||||
mimeType: file.type,
|
||||
|
|
@ -114,7 +115,7 @@ export function Upload({
|
|||
return (
|
||||
<div className="flex flex-col">
|
||||
<PageHeader
|
||||
description="Send a file (text, PDF, image, video) into a space."
|
||||
description="Send a document, image, audio, or video file into a space."
|
||||
title="Upload File"
|
||||
/>
|
||||
<div className="px-(--page-header-px) pb-(--space-6)">
|
||||
|
|
@ -144,7 +145,7 @@ export function Upload({
|
|||
) : (
|
||||
<FileUpload
|
||||
accept={ACCEPT}
|
||||
description="Supports TXT, PDF, PNG, JPG, MP4"
|
||||
description="Supports documents, images, audio, and video"
|
||||
onFile={setFile}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue