diff --git a/apps/mcp/src/widget/ErrorBoundary.tsx b/apps/mcp/src/widget/ErrorBoundary.tsx index 76489c2c..6dbe70b7 100644 --- a/apps/mcp/src/widget/ErrorBoundary.tsx +++ b/apps/mcp/src/widget/ErrorBoundary.tsx @@ -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 { state: State = { error: null } + static contextType = McpAppContext + declare context: ContextType static getDerivedStateFromError(error: Error): State { return { error } @@ -19,11 +26,18 @@ export class ErrorBoundary extends Component { 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) } diff --git a/apps/mcp/src/widget/McpAppProvider.tsx b/apps/mcp/src/widget/McpAppProvider.tsx new file mode 100644 index 00000000..0410df11 --- /dev/null +++ b/apps/mcp/src/widget/McpAppProvider.tsx @@ -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(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(initialViewState) + const [hostContext, setHostContext] = useState(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( + () => ({ + 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 ( + {children} + ) +} + +const previewValue: McpAppContextValue = { + app: null, + hostContext: null, + isConnected: false, + state: { kind: "loading" }, + setView: () => {}, + setError: () => {}, +} + +export function McpAppPreviewProvider({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} diff --git a/apps/mcp/src/widget/hooks/useApp.ts b/apps/mcp/src/widget/hooks/useApp.ts index b8f44fc2..01f67e66 100644 --- a/apps/mcp/src/widget/hooks/useApp.ts +++ b/apps/mcp/src/widget/hooks/useApp.ts @@ -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 { ok: boolean @@ -23,6 +23,12 @@ export interface ToolCallResult { * 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, ): Promise> { + 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 { + 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 diff --git a/apps/mcp/src/widget/hooks/useHostContext.ts b/apps/mcp/src/widget/hooks/useHostContext.ts index 742be62a..7dcc07e9 100644 --- a/apps/mcp/src/widget/hooks/useHostContext.ts +++ b/apps/mcp/src/widget/hooks/useHostContext.ts @@ -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( - () => 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 } diff --git a/apps/mcp/src/widget/hooks/useViewState.ts b/apps/mcp/src/widget/hooks/useViewState.ts index bb761b17..7f98c263 100644 --- a/apps/mcp/src/widget/hooks/useViewState.ts +++ b/apps/mcp/src/widget/hooks/useViewState.ts @@ -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(() => { - 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, } } diff --git a/apps/mcp/src/widget/lib/app.ts b/apps/mcp/src/widget/lib/app.ts deleted file mode 100644 index 81b023a4..00000000 --- a/apps/mcp/src/widget/lib/app.ts +++ /dev/null @@ -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" }) diff --git a/apps/mcp/src/widget/main.tsx b/apps/mcp/src/widget/main.tsx index f5e7196b..26b0718f 100644 --- a/apps/mcp/src/widget/main.tsx +++ b/apps/mcp/src/widget/main.tsx @@ -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( - - - + + + + + , ) - -// Establish the postMessage channel with the MCP host. -app.connect() diff --git a/apps/mcp/src/widget/studio/main.tsx b/apps/mcp/src/widget/studio/main.tsx index adbbd9f0..b5cfdac6 100644 --- a/apps/mcp/src/widget/studio/main.tsx +++ b/apps/mcp/src/widget/studio/main.tsx @@ -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( - + + + , ) diff --git a/apps/web/components/connect-ai-modal.tsx b/apps/web/components/connect-ai-modal.tsx index 2e590894..4e467567 100644 --- a/apps/web/components/connect-ai-modal.tsx +++ b/apps/web/components/connect-ai-modal.tsx @@ -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(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() - } }} /> @@ -714,48 +662,32 @@ export function ConnectAIModal({ ) } if (manual.kind === "generic-remote") { - const remoteSnippet = buildMcpUrlRemoteJson( - manualApiKey || "your-api-key-here", - ) + const remoteSnippet = buildMcpUrlRemoteJson() return (

- 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.

- {createMcpApiKeyMutation.isPending ? ( -
- -
- ) : ( - <> -
-
-																	
-																		{remoteSnippet}
-																	
-																
- -
-

- Bearer token uses your supermemory API key. -

- - )} +
+
+															
+																{remoteSnippet}
+															
+														
+ +
) } diff --git a/apps/web/components/mcp-modal/mcp-detail-view.tsx b/apps/web/components/mcp-modal/mcp-detail-view.tsx index 15b4f2f5..d7408671 100644 --- a/apps/web/components/mcp-modal/mcp-detail-view.tsx +++ b/apps/web/components/mcp-modal/mcp-detail-view.tsx @@ -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 (

- Add this to your client's MCP config. Replace the - placeholder with an API key from supermemory settings - (Integrations). + Add this to your client's MCP config. Your client + will open Supermemory OAuth when it first connects.

- {detailSetup?.oneClick ? ( -

- Use Bearer auth in headers, or switch to One click - setup and paste the HTTPS URL if your client - supports OAuth only. -

- ) : null}
) } diff --git a/apps/web/lib/mcp-manual-instructions.ts b/apps/web/lib/mcp-manual-instructions.ts index e5ecfd38..3c9fd9d1 100644 --- a/apps/web/lib/mcp-manual-instructions.ts +++ b/apps/web/lib/mcp-manual-instructions.ts @@ -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"] } }` }