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/client/index.ts b/apps/mcp/src/server/client/index.ts index 0beb9f8d7..220eea92a 100644 --- a/apps/mcp/src/server/client/index.ts +++ b/apps/mcp/src/server/client/index.ts @@ -377,7 +377,9 @@ export class SupermemoryClient { case 402: throw new Error("Memory limit reached. Upgrade at supermemory.ai") case 403: - throw new Error("Access forbidden.") + throw new Error( + `Access forbidden: you don't have access to container tag '${this.containerTag}', or it doesn't exist. Use listSpaces to see the available container tags.`, + ) case 404: throw new Error("Not found.") case 429: diff --git a/apps/mcp/src/server/tools/search-memory.ts b/apps/mcp/src/server/tools/search-memory.ts index 0c7ca4913..823f2f44e 100644 --- a/apps/mcp/src/server/tools/search-memory.ts +++ b/apps/mcp/src/server/tools/search-memory.ts @@ -1,6 +1,11 @@ import { z } from "zod" import { getMemoryText } from "../client" import type { ToolDeps } from "./types" +import { + containerTagValidationUnavailableError, + unknownContainerTagError, + validateContainerTag, +} from "./validate-container-tag" export function register(deps: ToolDeps) { const containerTagField: Record = deps.rbac @@ -44,6 +49,20 @@ export function register(deps: ToolDeps) { ) } 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 bc43fe6a5..8c1a19677 100644 --- a/apps/mcp/src/server/tools/set-active-tag.ts +++ b/apps/mcp/src/server/tools/set-active-tag.ts @@ -2,6 +2,11 @@ import { registerAppTool } from "@modelcontextprotocol/ext-apps/server" import { z } from "zod" import { SUPERMEMORY_RESOURCE_URI, type ViewMessage } from "../../shared/types" import type { ToolDeps } from "./types" +import { + containerTagValidationUnavailableError, + unknownContainerTagError, + validateContainerTag, +} from "./validate-container-tag" export function register(deps: ToolDeps) { registerAppTool( @@ -22,10 +27,27 @@ 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 { + 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.appErrorResult(error) + } await deps.storage.put("activeContainerTag", containerTag) const sc: ViewMessage = { view: "confirmation", 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 new file mode 100644 index 000000000..fcc2e7386 --- /dev/null +++ b/apps/mcp/src/server/tools/validate-container-tag.ts @@ -0,0 +1,30 @@ +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 validateContainerTag( + deps: ToolDeps, + containerTag: string, +): 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 { + return new 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..5f580cdcd 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 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}