From 276d35457de1111276e7acdc3eec5d1389c2c248 Mon Sep 17 00:00:00 2001 From: Ishaan Gupta Date: Sat, 4 Jul 2026 20:13:59 +0530 Subject: [PATCH] fix(mcp): show user-facing widget errors for invalid container tags Return structured error views from set-active-tag so the widget shows clear user-facing copy instead of a generic unrecognized-response error. Validate the resolved workspace in search_memory, and distinguish tag list refresh failures from genuinely missing tags. Co-authored-by: Cursor --- apps/mcp/src/server/agent.ts | 7 +++-- apps/mcp/src/server/tools/search-memory.ts | 23 +++++++++----- apps/mcp/src/server/tools/set-active-tag.ts | 23 ++++++++++---- apps/mcp/src/server/tools/types.ts | 31 ++++++++++++++++--- .../server/tools/validate-container-tag.ts | 17 +++++++--- apps/mcp/src/shared/types.ts | 6 ++++ apps/mcp/src/widget/App.tsx | 8 +++++ apps/mcp/src/widget/hooks/useViewState.ts | 17 ++++++++++ apps/mcp/src/widget/views/Error.tsx | 16 ++++++++-- 9 files changed, 120 insertions(+), 28 deletions(-) diff --git a/apps/mcp/src/server/agent.ts b/apps/mcp/src/server/agent.ts index 258fc060..a5dec716 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/search-memory.ts b/apps/mcp/src/server/tools/search-memory.ts index a4b7431b..823f2f44 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 31e65441..8c1a1967 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 eb005b0c..5be7297a 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 5ba0c860..7a6034ac 100644 --- a/apps/mcp/src/server/tools/validate-container-tag.ts +++ b/apps/mcp/src/server/tools/validate-container-tag.ts @@ -1,17 +1,20 @@ 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() - return deps.cachedContainerTags().includes(containerTag) +): 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 +22,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 1b5bf9cd..5f580cdc 100644 --- a/apps/mcp/src/shared/types.ts +++ b/apps/mcp/src/shared/types.ts @@ -92,6 +92,12 @@ export type ViewMessage = totalCount: number containerTag?: string } + | { + 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 736b9a65..03eb9372 100644 --- a/apps/mcp/src/widget/App.tsx +++ b/apps/mcp/src/widget/App.tsx @@ -139,6 +139,14 @@ 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 57d35c33..687458f8 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 fc6fa120..b1b70221 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}