diff --git a/apps/mcp/src/server/agent.ts b/apps/mcp/src/server/agent.ts index 258fc0600..a5dec716e 100644 --- a/apps/mcp/src/server/agent.ts +++ b/apps/mcp/src/server/agent.ts @@ -8,7 +8,7 @@ import { registerContainerTagsResource } from "./resources/container-tags" import { registerProfileResource } from "./resources/profile" import { registerWidgetResource } from "./resources/widget" import { registerAllTools } from "./tools" -import { errorResult } from "./tools/types" +import { appErrorResult, errorResult } from "./tools/types" type Env = { MCP_SERVER: DurableObjectNamespace @@ -71,6 +71,7 @@ export class SupermemoryMCP extends McpAgent { getClientInfo: () => this.clientInfo, getMcpSessionId: () => this.ctx.id.name ?? "unknown", errorResult, + appErrorResult, } registerAllTools(deps) @@ -103,13 +104,15 @@ export class SupermemoryMCP extends McpAgent { return activeTag || this.props?.containerTag } - private async refreshContainerTags(): Promise { + private async refreshContainerTags(): Promise { try { const client = this.getClient() const tags = await client.listContainerTags() this.cachedContainerTagsList = tags.map((t) => t.containerTag) + return true } catch (error) { console.error("Failed to refresh container tags:", error) + return false } } } diff --git a/apps/mcp/src/server/tools/fetch-graph-data.ts b/apps/mcp/src/server/tools/fetch-graph-data.ts index e2d2b083b..63aa9ccbd 100644 --- a/apps/mcp/src/server/tools/fetch-graph-data.ts +++ b/apps/mcp/src/server/tools/fetch-graph-data.ts @@ -1,6 +1,6 @@ import { registerAppTool } from "@modelcontextprotocol/ext-apps/server" import { z } from "zod" -import { SUPERMEMORY_RESOURCE_URI } from "../../shared/types" +import { SUPERMEMORY_RESOURCE_URI, type ViewMessage } from "../../shared/types" import type { ToolDeps } from "./types" export function register(deps: ToolDeps) { @@ -44,9 +44,24 @@ export function register(deps: ToolDeps) { args.limit, ) + // structuredContent must be a ViewMessage - the widget dispatches on + // `view` and renders anything else as an "unrecognized response" error. + const sc: ViewMessage = { + view: "graph", + containerTag: effectiveTag, + documents: data.documents, + totalCount: data.pagination.totalItems, + pagination: data.pagination, + } + return { - content: [{ type: "text" as const, text: JSON.stringify(data) }], - structuredContent: data, + content: [ + { + type: "text" as const, + text: `Fetched ${data.documents.length} documents (page ${data.pagination.currentPage} of ${data.pagination.totalPages}, ${data.pagination.totalItems} total)${effectiveTag ? `. Workspace: ${effectiveTag}` : ""}`, + }, + ], + structuredContent: sc, } } catch (error) { return deps.errorResult(error) diff --git a/apps/mcp/src/server/tools/search-memory.ts b/apps/mcp/src/server/tools/search-memory.ts index a4b7431b1..823f2f44e 100644 --- a/apps/mcp/src/server/tools/search-memory.ts +++ b/apps/mcp/src/server/tools/search-memory.ts @@ -2,8 +2,9 @@ import { z } from "zod" import { getMemoryText } from "../client" import type { ToolDeps } from "./types" import { - containerTagExists, + containerTagValidationUnavailableError, unknownContainerTagError, + validateContainerTag, } from "./validate-container-tag" export function register(deps: ToolDeps) { @@ -47,13 +48,21 @@ export function register(deps: ToolDeps) { ), ) } - if ( - args.containerTag && - !(await containerTagExists(deps, args.containerTag)) - ) { - return deps.errorResult(unknownContainerTagError(args.containerTag)) - } const effectiveTag = await deps.resolveContainerTag(args.containerTag) + if (effectiveTag && !deps.rbac.canRead(effectiveTag)) { + return deps.errorResult( + new Error(`No read access to container tag '${effectiveTag}'.`), + ) + } + if (effectiveTag) { + const validation = await validateContainerTag(deps, effectiveTag) + if (validation === "missing") { + return deps.errorResult(unknownContainerTagError(effectiveTag)) + } + if (validation === "unavailable") { + return deps.errorResult(containerTagValidationUnavailableError()) + } + } const client = deps.getClient(effectiveTag) const parts: string[] = [] diff --git a/apps/mcp/src/server/tools/set-active-tag.ts b/apps/mcp/src/server/tools/set-active-tag.ts index 31e654418..8c1a19677 100644 --- a/apps/mcp/src/server/tools/set-active-tag.ts +++ b/apps/mcp/src/server/tools/set-active-tag.ts @@ -3,8 +3,9 @@ import { z } from "zod" import { SUPERMEMORY_RESOURCE_URI, type ViewMessage } from "../../shared/types" import type { ToolDeps } from "./types" import { - containerTagExists, + containerTagValidationUnavailableError, unknownContainerTagError, + validateContainerTag, } from "./validate-container-tag" export function register(deps: ToolDeps) { @@ -26,16 +27,26 @@ export function register(deps: ToolDeps) { async (args) => { const containerTag = (args as { containerTag: string }).containerTag if (!deps.rbac.canRead(containerTag)) { - return deps.errorResult( - new Error(`No access to container tag '${containerTag}'.`), + return deps.appErrorResult( + new Error( + `You don't have access to '${containerTag}'. Choose a workspace from listSpaces.`, + ), + { kind: "user", title: "No access to this workspace" }, ) } try { - if (!(await containerTagExists(deps, containerTag))) { - return deps.errorResult(unknownContainerTagError(containerTag)) + const validation = await validateContainerTag(deps, containerTag) + if (validation === "missing") { + return deps.appErrorResult(unknownContainerTagError(containerTag), { + kind: "user", + title: "Workspace not found", + }) + } + if (validation === "unavailable") { + return deps.appErrorResult(containerTagValidationUnavailableError()) } } catch (error) { - return deps.errorResult(error) + return deps.appErrorResult(error) } await deps.storage.put("activeContainerTag", containerTag) const sc: ViewMessage = { diff --git a/apps/mcp/src/server/tools/types.ts b/apps/mcp/src/server/tools/types.ts index eb005b0c6..5be7297a0 100644 --- a/apps/mcp/src/server/tools/types.ts +++ b/apps/mcp/src/server/tools/types.ts @@ -1,5 +1,6 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" import type { Props } from "../../shared/types" +import type { ViewMessage } from "../../shared/types" import type { RbacContext } from "../auth/rbac" import type { SupermemoryClient } from "../client" @@ -16,13 +17,11 @@ export interface ToolDeps { put: (key: string, value: T) => Promise } cachedContainerTags: () => string[] - refreshContainerTags: () => Promise + refreshContainerTags: () => Promise getClientInfo: () => { name: string; version?: string } | null getMcpSessionId: () => string - errorResult: (error: unknown) => { - content: { type: "text"; text: string }[] - isError: true - } + errorResult: typeof errorResult + appErrorResult: typeof appErrorResult } export function errorResult(error: unknown) { @@ -33,3 +32,25 @@ export function errorResult(error: unknown) { isError: true as const, } } + +/** Like errorResult, but includes structuredContent so app widgets render the message. */ +export function appErrorResult( + error: unknown, + options?: { kind?: "user" | "system"; title?: string }, +) { + const message = + error instanceof Error ? error.message : "An unexpected error occurred" + const kind = options?.kind ?? "system" + const text = `Error: ${message}` + const structuredContent: ViewMessage = { + view: "error", + message, + kind, + title: options?.title, + } + return { + content: [{ type: "text" as const, text }], + isError: true as const, + structuredContent, + } +} diff --git a/apps/mcp/src/server/tools/validate-container-tag.ts b/apps/mcp/src/server/tools/validate-container-tag.ts index 5ba0c8602..fcc2e7386 100644 --- a/apps/mcp/src/server/tools/validate-container-tag.ts +++ b/apps/mcp/src/server/tools/validate-container-tag.ts @@ -1,17 +1,22 @@ import type { ToolDeps } from "./types" +export type ContainerTagValidation = "exists" | "missing" | "unavailable" + /** * Checks whether a container tag actually exists for this user. * Fast path is the cached tag list from init; on a miss we re-fetch once so * tags created after the session started are not falsely rejected. */ -export async function containerTagExists( +export async function validateContainerTag( deps: ToolDeps, containerTag: string, -): Promise { - if (deps.cachedContainerTags().includes(containerTag)) return true - await deps.refreshContainerTags() +): Promise { + if (deps.cachedContainerTags().includes(containerTag)) return "exists" + const refreshed = await deps.refreshContainerTags() + if (!refreshed) return "unavailable" return deps.cachedContainerTags().includes(containerTag) + ? "exists" + : "missing" } export function unknownContainerTagError(containerTag: string): Error { @@ -19,3 +24,7 @@ export function unknownContainerTagError(containerTag: string): Error { `Container tag '${containerTag}' does not exist. Use listSpaces to see the available container tags.`, ) } + +export function containerTagValidationUnavailableError(): Error { + return new Error("Could not verify workspace. Please try again.") +} diff --git a/apps/mcp/src/shared/types.ts b/apps/mcp/src/shared/types.ts index 1b5bf9cd2..835676f85 100644 --- a/apps/mcp/src/shared/types.ts +++ b/apps/mcp/src/shared/types.ts @@ -91,6 +91,13 @@ export type ViewMessage = documents: DocumentWithMemories[] totalCount: number containerTag?: string + pagination?: DocumentsApiResponse["pagination"] + } + | { + view: "error" + message: string + kind?: "user" | "system" + title?: string } export type ViewName = ViewMessage["view"] diff --git a/apps/mcp/src/widget/App.tsx b/apps/mcp/src/widget/App.tsx index 736b9a651..18d0c9ec8 100644 --- a/apps/mcp/src/widget/App.tsx +++ b/apps/mcp/src/widget/App.tsx @@ -139,6 +139,10 @@ function renderView( ) case "confirmation": return + case "error": + return ( + + ) case "save-success": return case "upload-success": diff --git a/apps/mcp/src/widget/hooks/useViewState.ts b/apps/mcp/src/widget/hooks/useViewState.ts index 57d35c330..687458f80 100644 --- a/apps/mcp/src/widget/hooks/useViewState.ts +++ b/apps/mcp/src/widget/hooks/useViewState.ts @@ -14,6 +14,11 @@ function safeLog( } } +function toolResultText(result: CallToolResult): string | null { + const item = result.content?.[0] + return item?.type === "text" ? item.text : null +} + type ViewState = | { kind: "loading" } | { kind: "view"; message: ViewMessage } @@ -54,8 +59,20 @@ export function useViewState(): { setState({ kind: "loading" }) } app.ontoolresult = (result: CallToolResult) => { + if (result.isError) { + const text = toolResultText(result) ?? "Tool returned an error" + safeLog("error", `[host] ontoolresult: tool error: ${text}`) + setState({ kind: "error", message: text }) + return + } const sc = (result as { structuredContent?: unknown }).structuredContent if (!sc || typeof sc !== "object") { + const text = toolResultText(result) + if (text) { + safeLog("warning", `[host] ontoolresult: text-only result: ${text}`) + setState({ kind: "error", message: text }) + return + } safeLog("warning", "[host] ontoolresult: no structuredContent") setState({ kind: "raw", structuredContent: sc }) return diff --git a/apps/mcp/src/widget/views/Error.tsx b/apps/mcp/src/widget/views/Error.tsx index fc6fa120f..b1b70221f 100644 --- a/apps/mcp/src/widget/views/Error.tsx +++ b/apps/mcp/src/widget/views/Error.tsx @@ -3,18 +3,28 @@ import { WarningCircle } from "../lib/icons" interface Props { message: string + kind?: "user" | "system" + title?: string } -export function ErrorView({ message }: Props) { +export function ErrorView({ message, kind = "system", title }: Props) { + const heading = + title ?? + (kind === "user" ? "Couldn't complete that" : "Something went wrong") + return ( - +
- Something went wrong + {heading}

{message} diff --git a/apps/mcp/src/widget/views/Upload.tsx b/apps/mcp/src/widget/views/Upload.tsx index 63cd4a36c..6e34490b2 100644 --- a/apps/mcp/src/widget/views/Upload.tsx +++ b/apps/mcp/src/widget/views/Upload.tsx @@ -39,14 +39,15 @@ const MAX_UPLOAD_BYTES = Math.floor( ((TRANSPORT_MESSAGE_LIMIT_BYTES - ENVELOPE_ALLOWANCE_BYTES) * 3) / 4, ) -const FILE_TOO_LARGE_MESSAGE = (size: number) => - `This file is ${formatFileSize(size)}. The maximum upload size is ${formatFileSize(MAX_UPLOAD_BYTES)} — please choose a smaller file.` +function fileTooLargeMessage(size: number): string { + return `This file is ${formatFileSize(size)}. The maximum upload size is ${formatFileSize(MAX_UPLOAD_BYTES)} — please choose a smaller file.` +} // The transport-level 413 surfaces as an opaque error string; translate it // so the user sees the size limit instead of a generic failure. function friendlyUploadError(raw: string, fileSize: number): string { if (/413|too large|payload/i.test(raw)) { - return FILE_TOO_LARGE_MESSAGE(fileSize) + return fileTooLargeMessage(fileSize) } return raw } @@ -61,13 +62,13 @@ export function Upload({ activeTag, writableTags, onAdvance, onError }: Props) { ) const [uploading, setUploading] = useState(false) - const handleFileSelect = (selected: File) => { + function handleFileSelect(selected: File) { if (selected.size > MAX_UPLOAD_BYTES) { log( "warning", `[upload] rejected oversized file: ${selected.name} (${selected.size}B > ${MAX_UPLOAD_BYTES}B)`, ) - setFileError(FILE_TOO_LARGE_MESSAGE(selected.size)) + setFileError(fileTooLargeMessage(selected.size)) setFile(null) return }