Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions apps/mcp/src/server/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
}
}
21 changes: 18 additions & 3 deletions apps/mcp/src/server/tools/fetch-graph-data.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

structuredContent: sc drops the existing pagination object even though fetch-graph-data still accepts page and limit. Existing paginated callers lose currentPage, limit, and totalPages, and apps/mcp/e2e/graph.test.ts still expects res.structuredContent.pagination.limit to be 5. Please keep the view: "graph" widget shape, but preserve pagination metadata in structuredContent or update the contract and all affected tests/callers together.

}
} catch (error) {
return deps.errorResult(error)
Expand Down
23 changes: 16 additions & 7 deletions apps/mcp/src/server/tools/search-memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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[] = []
Expand Down
23 changes: 17 additions & 6 deletions apps/mcp/src/server/tools/set-active-tag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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 = {
Expand Down
31 changes: 26 additions & 5 deletions apps/mcp/src/server/tools/types.ts
Original file line number Diff line number Diff line change
@@ -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"

Expand All @@ -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) {
Expand All @@ -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,
}
}
17 changes: 13 additions & 4 deletions apps/mcp/src/server/tools/validate-container-tag.ts
Original file line number Diff line number Diff line change
@@ -1,21 +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 containerTagExists(
export async function validateContainerTag(
deps: ToolDeps,
containerTag: string,
): Promise<boolean> {
if (deps.cachedContainerTags().includes(containerTag)) return true
await deps.refreshContainerTags()
): 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 {
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.")
}
7 changes: 7 additions & 0 deletions apps/mcp/src/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
4 changes: 4 additions & 0 deletions apps/mcp/src/widget/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,10 @@ 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":
Expand Down
17 changes: 17 additions & 0 deletions apps/mcp/src/widget/hooks/useViewState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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
}
Comment on lines +62 to +67

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When result.isError is true, the code immediately returns without checking structuredContent. This breaks the appErrorResult function which returns both isError: true AND structuredContent with view: "error" to provide rich error details (kind, title, etc.).

The early return means the structured error view with its kind, title, and formatted message is never rendered. Instead, only the plain text error is shown.

Fix: Check for structured error content first before falling back to text:

if (result.isError) {
	const sc = (result as { structuredContent?: unknown }).structuredContent
	if (sc && typeof sc === "object" && "view" in sc && sc.view === "error") {
		// Use structured error content if available
		if (isViewMessage(sc)) {
			safeLog("info", `[host] ontoolresult: structured error view`)
			setState({ kind: "view", message: sc })
			return
		}
	}
	const text = toolResultText(result) ?? "Tool returned an error"
	safeLog("error", `[host] ontoolresult: tool error: ${text}`)
	setState({ kind: "error", message: text })
	return
}
Suggested change
if (result.isError) {
const text = toolResultText(result) ?? "Tool returned an error"
safeLog("error", `[host] ontoolresult: tool error: ${text}`)
setState({ kind: "error", message: text })
return
}
if (result.isError) {
const sc = (result as { structuredContent?: unknown }).structuredContent
if (sc && typeof sc === "object" && "view" in sc && sc.view === "error") {
// Use structured error content if available
if (isViewMessage(sc)) {
safeLog("info", `[host] ontoolresult: structured error view`)
setState({ kind: "view", message: sc })
return
}
}
const text = toolResultText(result) ?? "Tool returned an error"
safeLog("error", `[host] ontoolresult: tool error: ${text}`)
setState({ kind: "error", message: text })
return
}

Spotted by Graphite

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

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
Expand Down
16 changes: 13 additions & 3 deletions apps/mcp/src/widget/views/Error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
11 changes: 6 additions & 5 deletions apps/mcp/src/widget/views/Upload.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
}
Expand Down
Loading