mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-09-05 08:06:19 +00:00
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 <cursoragent@cursor.com>
This commit is contained in:
parent
df671c6e41
commit
276d35457d
9 changed files with 120 additions and 28 deletions
|
|
@ -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<Env, unknown, Props> {
|
|||
getClientInfo: () => this.clientInfo,
|
||||
getMcpSessionId: () => this.ctx.id.name ?? "unknown",
|
||||
errorResult,
|
||||
appErrorResult,
|
||||
}
|
||||
|
||||
registerAllTools(deps)
|
||||
|
|
@ -103,13 +104,15 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
|
|||
return activeTag || this.props?.containerTag
|
||||
}
|
||||
|
||||
private async refreshContainerTags(): Promise<void> {
|
||||
private async refreshContainerTags(): Promise<boolean> {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[] = []
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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: <T>(key: string, value: T) => Promise<void>
|
||||
}
|
||||
cachedContainerTags: () => string[]
|
||||
refreshContainerTags: () => Promise<void>
|
||||
refreshContainerTags: () => Promise<boolean>
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<boolean> {
|
||||
if (deps.cachedContainerTags().includes(containerTag)) return true
|
||||
await deps.refreshContainerTags()
|
||||
return deps.cachedContainerTags().includes(containerTag)
|
||||
): Promise<ContainerTagValidation> {
|
||||
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.")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
||||
|
|
|
|||
|
|
@ -139,6 +139,14 @@ function renderView(
|
|||
)
|
||||
case "confirmation":
|
||||
return <Confirmation containerTag={msg.containerTag} />
|
||||
case "error":
|
||||
return (
|
||||
<ErrorView
|
||||
kind={msg.kind}
|
||||
message={msg.message}
|
||||
title={msg.title}
|
||||
/>
|
||||
)
|
||||
case "save-success":
|
||||
return <Success containerTag={msg.containerTag} id={msg.id} kind="save" />
|
||||
case "upload-success":
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Stack
|
||||
align="center"
|
||||
className="mx-(--page-header-px) my-(--space-6) rounded-[20px] bg-[#1B1F24] px-(--page-header-px) py-(--space-10) text-center shadow-[0_2.842px_14.211px_0_rgba(0,0,0,0.25),inset_0.711px_0.711px_0.711px_0_rgba(255,255,255,0.10)]"
|
||||
gap="md"
|
||||
>
|
||||
<WarningCircle className="size-12 text-error" />
|
||||
<WarningCircle
|
||||
className={
|
||||
kind === "user" ? "size-12 text-warning" : "size-12 text-error"
|
||||
}
|
||||
/>
|
||||
<div className="text-(length:--text-sm) font-medium text-text-primary">
|
||||
Something went wrong
|
||||
{heading}
|
||||
</div>
|
||||
<p className="max-w-sm text-(length:--text-xs) leading-relaxed text-text-muted">
|
||||
{message}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue