fix mcp widget lifecycle and setup

This commit is contained in:
Prasanna A P 2026-07-30 00:20:06 -07:00
parent 4c2486fa38
commit 14c4f1076c
11 changed files with 262 additions and 233 deletions

View file

@ -1,6 +1,11 @@
import { Component, type ErrorInfo, type ReactNode } from "react"
import {
Component,
type ContextType,
type ErrorInfo,
type ReactNode,
} from "react"
import { Button, Stack } from "./design/ui"
import { app } from "./lib/app"
import { McpAppContext } from "./McpAppProvider"
interface Props {
children: ReactNode
@ -12,6 +17,8 @@ interface State {
export class ErrorBoundary extends Component<Props, State> {
state: State = { error: null }
static contextType = McpAppContext
declare context: ContextType<typeof McpAppContext>
static getDerivedStateFromError(error: Error): State {
return { error }
@ -19,11 +26,18 @@ export class ErrorBoundary extends Component<Props, State> {
componentDidCatch(error: Error, info: ErrorInfo) {
try {
app.sendLog({
const report = this.context?.app?.sendLog({
level: "error",
logger: "ErrorBoundary",
data: `${error.name}: ${error.message}\n${info.componentStack ?? ""}`,
})
if (report) {
void report.catch(() => {
console.error("[ErrorBoundary]", error, info)
})
} else {
console.error("[ErrorBoundary]", error, info)
}
} catch {
console.error("[ErrorBoundary]", error, info)
}

View file

@ -0,0 +1,157 @@
import type {
App as McpApp,
McpUiHostContext,
} from "@modelcontextprotocol/ext-apps"
import { useApp as useMcpApp } from "@modelcontextprotocol/ext-apps/react"
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"
import {
createContext,
type ReactNode,
useCallback,
useMemo,
useState,
} from "react"
import type { ViewMessage } from "../shared/types"
import { loadViewCheckpoint, saveViewCheckpoint } from "./lib/viewCheckpoint"
export type ViewState =
| { kind: "loading" }
| { kind: "view"; message: ViewMessage }
| { kind: "error"; message: string }
| { kind: "raw"; structuredContent: unknown }
export interface McpAppContextValue {
app: McpApp | null
hostContext: McpUiHostContext | null
isConnected: boolean
state: ViewState
setView: (message: ViewMessage) => void
setError: (message: string) => void
}
export const McpAppContext = createContext<McpAppContextValue | null>(null)
function safeLog(
app: McpApp,
level: "debug" | "info" | "warning" | "error",
message: string,
) {
try {
void app.sendLog({ level, data: message }).catch(() => {
// Host logging is optional.
})
} catch {
// The transport may not be ready yet.
}
}
function initialViewState(): ViewState {
const checkpoint = loadViewCheckpoint()
return checkpoint
? { kind: "view", message: checkpoint }
: { kind: "loading" }
}
export function McpAppProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<ViewState>(initialViewState)
const [hostContext, setHostContext] = useState<McpUiHostContext | null>(null)
const { app, isConnected, error } = useMcpApp({
appInfo: { name: "Supermemory MCP", version: "1.0.0" },
capabilities: {},
strict: true,
onAppCreated: (createdApp) => {
createdApp.ontoolinput = (input: unknown) => {
const name =
typeof input === "object" && input !== null && "name" in input
? String((input as { name: unknown }).name)
: "?"
safeLog(createdApp, "info", `[host] ontoolinput: ${name}`)
setState({ kind: "loading" })
}
createdApp.ontoolinputpartial = () => setState({ kind: "loading" })
createdApp.ontoolcancelled = () => {
safeLog(createdApp, "info", "[host] ontoolcancelled")
setState({ kind: "loading" })
}
createdApp.ontoolresult = (result: CallToolResult) => {
const structuredContent = (result as { structuredContent?: unknown })
.structuredContent
if (!structuredContent || typeof structuredContent !== "object") {
safeLog(
createdApp,
"warning",
"[host] ontoolresult: no structuredContent",
)
setState({ kind: "raw", structuredContent })
return
}
if ("view" in structuredContent) {
const message = structuredContent as ViewMessage
safeLog(
createdApp,
"info",
`[host] ontoolresult: view=${message.view}`,
)
const checkpoint = loadViewCheckpoint(message.viewId)
setState({ kind: "view", message: checkpoint ?? message })
return
}
safeLog(
createdApp,
"warning",
"[host] ontoolresult: structuredContent without view",
)
setState({ kind: "raw", structuredContent })
}
createdApp.onhostcontextchanged = (next) => {
setHostContext(createdApp.getHostContext() ?? next)
}
createdApp.onerror = (nextError: unknown) => {
safeLog(createdApp, "error", `[host] onerror: ${String(nextError)}`)
setState({ kind: "error", message: String(nextError) })
}
},
})
const setView = useCallback((message: ViewMessage) => {
saveViewCheckpoint(message)
setState({ kind: "view", message })
}, [])
const setError = useCallback((message: string) => {
setState({ kind: "error", message })
}, [])
const value = useMemo<McpAppContextValue>(
() => ({
app,
hostContext: hostContext ?? app?.getHostContext() ?? null,
isConnected,
state: error ? { kind: "error", message: error.message } : state,
setView,
setError,
}),
[app, error, hostContext, isConnected, setError, setView, state],
)
return (
<McpAppContext.Provider value={value}>{children}</McpAppContext.Provider>
)
}
const previewValue: McpAppContextValue = {
app: null,
hostContext: null,
isConnected: false,
state: { kind: "loading" },
setView: () => {},
setError: () => {},
}
export function McpAppPreviewProvider({ children }: { children: ReactNode }) {
return (
<McpAppContext.Provider value={previewValue}>
{children}
</McpAppContext.Provider>
)
}

View file

@ -1,11 +1,11 @@
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"
import { useMemo } from "react"
import { useContext, useMemo } from "react"
import type { ViewMessage } from "../../shared/types"
import { app } from "../lib/app"
import {
handoffToModel as performModelHandoff,
type ModelHandoffRequest,
} from "../lib/modelHandoff"
import { McpAppContext } from "../McpAppProvider"
export interface ToolCallResult<T = unknown> {
ok: boolean
@ -23,6 +23,12 @@ export interface ToolCallResult<T = unknown> {
* that class of mistake impossible: there is no `callTool` exposed.
*/
export function useApp() {
const context = useContext(McpAppContext)
if (!context) {
throw new Error("useApp must be used within McpAppProvider")
}
const { app } = context
return useMemo(() => {
return {
/** Call an MCP server tool and await the result. */
@ -30,6 +36,9 @@ export function useApp() {
name: string,
args: Record<string, unknown>,
): Promise<ToolCallResult<T>> {
if (!app) {
return { ok: false, error: "MCP host is not connected" }
}
try {
const result = (await app.callServerTool({
name,
@ -53,6 +62,9 @@ export function useApp() {
/** Make widget state available to the model on a future turn. */
async updateModelContext(content: string): Promise<ToolCallResult> {
if (!app) {
return { ok: false, error: "MCP host is not connected" }
}
try {
await app.updateModelContext({
content: [{ type: "text", text: content }],
@ -69,24 +81,36 @@ export function useApp() {
* rejects or drops model-context updates.
*/
handoffToModel(request: ModelHandoffRequest) {
if (!app) {
const error = "MCP host is not connected"
return Promise.resolve({
ok: false,
contextUpdate: { ok: false, error },
conversationMessage: { ok: false, error },
})
}
return performModelHandoff(app, request)
},
/** Send a structured log line to the host. */
log(level: "debug" | "info" | "warning" | "error", message: string) {
return app.sendLog({ level, data: message })
return app
? app.sendLog({ level, data: message })
: Promise.resolve(undefined)
},
/** Request the host to switch display mode. */
requestDisplayMode(mode: "inline" | "fullscreen" | "pip") {
return app.requestDisplayMode({ mode })
return app
? app.requestDisplayMode({ mode })
: Promise.resolve({ mode })
},
getHostContext() {
return app.getHostContext()
return app?.getHostContext()
},
}
}, [])
}, [app])
}
export type AppApi = ReturnType<typeof useApp>

View file

@ -1,25 +1,15 @@
import type { McpUiHostContext } from "@modelcontextprotocol/ext-apps"
import { useEffect, useState } from "react"
import { app } from "../lib/app"
import { useContext } from "react"
import { McpAppContext } from "../McpAppProvider"
/**
* Subscribes to host context changes (theme, dimensions, displayMode, fonts).
* Returns the latest context, or null until the connection is established.
* Returns the provider-owned host context, including updates received after
* the connection handshake.
*/
export function useHostContext(): McpUiHostContext | null {
const [ctx, setCtx] = useState<McpUiHostContext | null>(
() => app.getHostContext() ?? null,
)
useEffect(() => {
const handler = (next: McpUiHostContext) => setCtx(next)
app.onhostcontextchanged = handler
return () => {
if (app.onhostcontextchanged === handler) {
app.onhostcontextchanged = () => {}
}
}
}, [])
return ctx
const context = useContext(McpAppContext)
if (!context) {
throw new Error("useHostContext must be used within McpAppProvider")
}
return context.hostContext
}

View file

@ -1,28 +1,8 @@
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"
import { useEffect, useState } from "react"
import type { ViewMessage } from "../../shared/types"
import { app } from "../lib/app"
import { loadViewCheckpoint, saveViewCheckpoint } from "../lib/viewCheckpoint"
function safeLog(
level: "debug" | "info" | "warning" | "error",
message: string,
) {
try {
void app.sendLog({ level, data: message })
} catch {
// host may not support logging — ignore
}
}
type ViewState =
| { kind: "loading" }
| { kind: "view"; message: ViewMessage }
| { kind: "error"; message: string }
| { kind: "raw"; structuredContent: unknown }
import { useContext } from "react"
import { McpAppContext, type McpAppContextValue } from "../McpAppProvider"
/**
* Drives the widget's top-level view state from MCP host events.
* Exposes the top-level view state owned by McpAppProvider.
*
* - `ontoolinput` / `ontoolinputpartial`: shows loading
* - `ontoolresult`: parses `structuredContent` as `ViewMessage` and renders
@ -34,68 +14,17 @@ type ViewState =
* not flow through this hook.
*/
export function useViewState(): {
state: ViewState
setView: (msg: ViewMessage) => void
state: McpAppContextValue["state"]
setView: McpAppContextValue["setView"]
setError: (message: string) => void
} {
const [state, setState] = useState<ViewState>(() => {
const checkpoint = loadViewCheckpoint()
return checkpoint
? { kind: "view", message: checkpoint }
: { kind: "loading" }
})
useEffect(() => {
app.ontoolinput = (input: unknown) => {
const name =
typeof input === "object" && input !== null && "name" in input
? String((input as { name: unknown }).name)
: "?"
safeLog("info", `[host] ontoolinput: ${name}`)
setState({ kind: "loading" })
}
app.ontoolinputpartial = () => setState({ kind: "loading" })
app.ontoolcancelled = () => {
safeLog("info", "[host] ontoolcancelled")
setState({ kind: "loading" })
}
app.ontoolresult = (result: CallToolResult) => {
const sc = (result as { structuredContent?: unknown }).structuredContent
if (!sc || typeof sc !== "object") {
safeLog("warning", "[host] ontoolresult: no structuredContent")
setState({ kind: "raw", structuredContent: sc })
return
}
if ("view" in sc) {
const msg = sc as ViewMessage
safeLog("info", `[host] ontoolresult: view=${msg.view}`)
const checkpoint = loadViewCheckpoint(msg.viewId)
setState({ kind: "view", message: checkpoint ?? msg })
return
}
safeLog("warning", "[host] ontoolresult: structuredContent without view")
setState({ kind: "raw", structuredContent: sc })
}
app.onerror = (error: unknown) => {
safeLog("error", `[host] onerror: ${String(error)}`)
setState({ kind: "error", message: String(error) })
}
return () => {
app.ontoolinput = () => {}
app.ontoolinputpartial = () => {}
app.ontoolcancelled = () => {}
app.ontoolresult = () => {}
app.onerror = undefined
}
}, [])
const context = useContext(McpAppContext)
if (!context) {
throw new Error("useViewState must be used within McpAppProvider")
}
return {
state,
setView: (msg) => {
saveViewCheckpoint(msg)
setState({ kind: "view", message: msg })
},
setError: (message) => setState({ kind: "error", message }),
state: context.state,
setView: context.setView,
setError: context.setError,
}
}

View file

@ -1,7 +0,0 @@
/**
* Singleton App instance one MCP App connection per widget.
* Imported only via the `useApp()` hook for typed access.
*/
import { App } from "@modelcontextprotocol/ext-apps"
export const app = new App({ name: "Supermemory MCP", version: "1.0.0" })

View file

@ -2,17 +2,16 @@ import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import { App as WidgetApp } from "./App"
import { ErrorBoundary } from "./ErrorBoundary"
import { app } from "./lib/app"
import { McpAppProvider } from "./McpAppProvider"
import "./design/globals.css"
const root = createRoot(document.getElementById("app") as HTMLElement)
root.render(
<StrictMode>
<ErrorBoundary>
<WidgetApp />
</ErrorBoundary>
<McpAppProvider>
<ErrorBoundary>
<WidgetApp />
</ErrorBoundary>
</McpAppProvider>
</StrictMode>,
)
// Establish the postMessage channel with the MCP host.
app.connect()

View file

@ -1,5 +1,6 @@
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import { McpAppPreviewProvider } from "../McpAppProvider"
import { Studio } from "./Studio"
import "../design/globals.css"
@ -10,6 +11,8 @@ document.documentElement.setAttribute("data-theme", "light")
const root = createRoot(document.getElementById("studio") as HTMLElement)
root.render(
<StrictMode>
<Studio />
<McpAppPreviewProvider>
<Studio />
</McpAppPreviewProvider>
</StrictMode>,
)

View file

@ -1,9 +1,6 @@
"use client"
import { $fetch } from "@lib/api"
import { authClient } from "@lib/auth"
import { useAuth } from "@lib/auth-context"
import { generateId } from "@lib/generate-id"
import { useForm } from "@tanstack/react-form"
import { useMutation, useQuery } from "@tanstack/react-query"
import { Button } from "@ui/components/button"
@ -130,7 +127,6 @@ export function ConnectAIModal({
openInitialClient,
openInitialTab,
}: ConnectAIModalProps) {
const { org } = useAuth()
const [selectedClient, setSelectedClient] = useState<
keyof typeof clients | null
>(openInitialClient || null)
@ -142,7 +138,6 @@ export function ConnectAIModal({
const [setupTab, setSetupTab] = useState<"oneClick" | "manual">(
openInitialTab ?? "manual",
)
const [manualApiKey, setManualApiKey] = useState<string | null>(null)
const [isCopied, setIsCopied] = useState(false)
const [projectId, setProjectId] = useState("default")
@ -236,33 +231,6 @@ export function ConnectAIModal({
},
})
const createMcpApiKeyMutation = useMutation({
mutationFn: async () => {
if (!org?.id) {
throw new Error("Organization ID is required")
}
const res = await authClient.apiKey.create({
metadata: {
organizationId: org?.id,
type: "mcp-manual",
},
name: `mcp-manual-${generateId().slice(0, 8)}`,
prefix: `sm_${org?.id}_`,
})
return res.key
},
onSuccess: (apiKey) => {
setManualApiKey(apiKey)
toast.success("API key created successfully!")
},
onError: (error) => {
toast.error("Failed to create API key", {
description: error instanceof Error ? error.message : "Unknown error",
})
},
})
useEffect(() => {
if (openInitialClient) {
setSelectedClient(openInitialClient as keyof typeof clients)
@ -279,20 +247,6 @@ export function ConnectAIModal({
if (!s.manual && setupTab === "manual") setSetupTab("oneClick")
}, [selectedClient, setupTab])
useEffect(() => {
if (selectedClient !== "mcp-url" || setupTab !== "manual" || !org?.id)
return
if (manualApiKey || createMcpApiKeyMutation.isPending) return
createMcpApiKeyMutation.mutate()
}, [
selectedClient,
setupTab,
org?.id,
manualApiKey,
createMcpApiKeyMutation.isPending,
createMcpApiKeyMutation.mutate,
])
function getMcpServerUrl() {
return "https://mcp.supermemory.ai/mcp"
}
@ -496,12 +450,6 @@ export function ConnectAIModal({
onClick={() => {
setSelectedClient("mcp-url")
setSetupTab("manual")
if (
!manualApiKey &&
!createMcpApiKeyMutation.isPending
) {
createMcpApiKeyMutation.mutate()
}
}}
/>
</div>
@ -714,48 +662,32 @@ export function ConnectAIModal({
)
}
if (manual.kind === "generic-remote") {
const remoteSnippet = buildMcpUrlRemoteJson(
manualApiKey || "your-api-key-here",
)
const remoteSnippet = buildMcpUrlRemoteJson()
return (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Paste into your MCP config. We create an API key for
you when you open this tab; copy the block after it
appears.
Paste this into your MCP config. Your client will
open Supermemory OAuth when it first connects.
</p>
{createMcpApiKeyMutation.isPending ? (
<div className="flex items-center justify-center p-8">
<Loader2 className="size-6 animate-spin text-primary" />
</div>
) : (
<>
<div className="relative min-w-0 max-w-full">
<pre className="max-h-80 max-w-full overflow-x-auto overflow-y-auto rounded-lg border border-border bg-muted p-3 pr-12 text-xs sm:p-4">
<code className="block font-mono whitespace-pre-wrap break-all">
{remoteSnippet}
</code>
</pre>
<Button
className="absolute top-2 right-2 size-8 cursor-pointer p-0 bg-muted/80 hover:bg-muted"
onClick={() =>
copyManualSnippet(remoteSnippet)
}
size="icon"
variant="ghost"
>
{isCopied ? (
<CheckIcon className="size-3.5 text-green-600" />
) : (
<CopyIcon className="size-3.5" />
)}
</Button>
</div>
<p className="text-xs text-muted-foreground">
Bearer token uses your supermemory API key.
</p>
</>
)}
<div className="relative min-w-0 max-w-full">
<pre className="max-h-80 max-w-full overflow-x-auto overflow-y-auto rounded-lg border border-border bg-muted p-3 pr-12 text-xs sm:p-4">
<code className="block font-mono whitespace-pre-wrap break-all">
{remoteSnippet}
</code>
</pre>
<Button
className="absolute top-2 right-2 size-8 cursor-pointer p-0 bg-muted/80 hover:bg-muted"
onClick={() => copyManualSnippet(remoteSnippet)}
size="icon"
variant="ghost"
>
{isCopied ? (
<CheckIcon className="size-3.5 text-green-600" />
) : (
<CopyIcon className="size-3.5" />
)}
</Button>
</div>
</div>
)
}

View file

@ -557,13 +557,12 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) {
)
}
if (manual.kind === "generic-remote") {
const snippet = buildMcpUrlRemoteJson("your-api-key-here")
const snippet = buildMcpUrlRemoteJson()
return (
<div className="space-y-3">
<p className="text-[13px] leading-relaxed text-[#A1A1AA]">
Add this to your client&apos;s MCP config. Replace the
placeholder with an API key from supermemory settings
(Integrations).
Add this to your client&apos;s MCP config. Your client
will open Supermemory OAuth when it first connects.
</p>
<McpCodeBlock
code={snippet}
@ -573,13 +572,6 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) {
setActiveStep(3)
}}
/>
{detailSetup?.oneClick ? (
<p className="text-[12px] text-[#737373]">
Use Bearer auth in headers, or switch to One click
setup and paste the HTTPS URL if your client
supports OAuth only.
</p>
) : null}
</div>
)
}

View file

@ -16,15 +16,11 @@ export const ANTIGRAVITY_MCP_SNIPPET = `{
}
}`
export function buildMcpUrlRemoteJson(apiKeyPlaceholder: string) {
export function buildMcpUrlRemoteJson() {
return `{
"supermemory-mcp": {
"command": "npx",
"args": ["-y", "mcp-remote", "https://mcp.supermemory.ai/mcp"],
"env": {},
"headers": {
"Authorization": "Bearer ${apiKeyPlaceholder}"
}
"args": ["-y", "mcp-remote@latest", "https://mcp.supermemory.ai/mcp"]
}
}`
}