diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index cae565b5..1305acd4 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -1,5 +1,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useComputedColorScheme, useMantineColorScheme } from "@mantine/core"; +import { + Anchor, + Stack, + Text, + useComputedColorScheme, + useMantineColorScheme, +} from "@mantine/core"; import { notifications } from "@mantine/notifications"; import type { InitializeResult, @@ -7,7 +13,6 @@ import type { LoggingMessageNotification, Tool, } from "@modelcontextprotocol/sdk/types.js"; -import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge"; import { InspectorClient } from "@inspector/core/mcp/index.js"; import type { JsonValue } from "@inspector/core/mcp/index.js"; import type { @@ -49,11 +54,13 @@ import { useManagedRequestorTasks } from "@inspector/core/react/useManagedReques import { useResourceSubscriptions } from "@inspector/core/react/useResourceSubscriptions.js"; import { useMessageLog } from "@inspector/core/react/useMessageLog.js"; import { useFetchRequestLog } from "@inspector/core/react/useFetchRequestLog.js"; +import { useSandboxUrl } from "@inspector/core/react/useSandboxUrl.js"; import { InspectorView } from "./components/views/InspectorView/InspectorView"; import type { ToolCallState } from "./components/screens/ToolsScreen/ToolsScreen"; import type { GetPromptState } from "./components/screens/PromptsScreen/PromptsScreen"; import type { ReadResourceState } from "./components/screens/ResourcesScreen/ResourcesScreen"; -import type { BridgeFactory } from "./components/elements/AppRenderer/AppRenderer"; +import type { AppRendererHandle } from "./components/elements/AppRenderer/AppRenderer"; +import { createAppBridgeFactory } from "./components/elements/AppRenderer/createAppBridgeFactory"; import type { LogEntryData } from "./components/elements/LogEntry/LogEntry"; import { ServerConfigModal, @@ -61,6 +68,7 @@ import { } from "./components/groups/ServerConfigModal/ServerConfigModal"; import { ServerSettingsModal } from "./components/groups/ServerSettingsModal/ServerSettingsModal"; import { ConnectionInfoModal } from "./components/groups/ConnectionInfoModal/ConnectionInfoModal"; +import { OutputValidationModal } from "./components/groups/OutputValidationModal/OutputValidationModal"; import type { OAuthDetails } from "./components/groups/ConnectionInfoContent/ConnectionInfoContent"; import { ServerRemoveConfirmModal } from "./components/groups/ServerRemoveConfirmModal/ServerRemoveConfirmModal"; import { buildExportFilename, downloadJsonFile } from "./lib/downloadFile"; @@ -126,23 +134,6 @@ function getAuthToken(): string | undefined { } } -// MCP Apps sandbox — the iframe URL the parent should embed, plus the -// per-tool bridge factory. The dev backend serves `sandbox_proxy.html` on -// the sandbox controller port; the factory will eventually wrap the SDK -// client. For now neither is wired (the Apps tab uses these props but does -// not yet round-trip tool input through a live bridge — that's a follow-up -// alongside the AppRenderer integration). Keep these as stable references -// so InspectorView's effect deps don't churn. -const STUB_SANDBOX_PATH = "about:blank"; -const stubBridgeFactory: BridgeFactory = () => - ({ - sendToolInput: async () => {}, - sendToolResult: async () => {}, - sendToolCancelled: async () => {}, - teardownResource: async () => ({}), - close: async () => {}, - }) as unknown as AppBridge; - // Derive `LogEntryData[]` from the MessageLog by filtering for the // `notifications/message` notifications the server emits in response to // `logging/setLevel`. The Logs screen renders these; we transform here @@ -177,6 +168,26 @@ const EMPTY_SETTINGS: InspectorServerSettings = { requestTimeout: 0, }; +// Body of the output-schema-mismatch warning toast: a one-line summary plus a +// link that opens the full validation details in a modal (the raw error is far +// too long for a toast). +const OutputValidationToastMessage = ({ + onViewDetails, +}: { + onViewDetails: () => void; +}) => ( + + + The tool result's structuredContent doesn't match the + tool's outputSchema. The inspector renders it anyway, but strict MCP + clients may not. + + + View validation details + + +); + function App() { // Theme toggle plumbing (preserved from the pre-wire placeholder). const { setColorScheme } = useMantineColorScheme(); @@ -214,6 +225,11 @@ function App() { >(undefined); const [connectionInfoModalOpen, setConnectionInfoModalOpen] = useState(false); const [removeTarget, setRemoveTarget] = useState(null); + // Details for the output-schema-mismatch modal opened from the warning toast. + const [outputValidationDetails, setOutputValidationDetails] = useState<{ + toolName: string; + message: string; + } | null>(null); // The active connection target. `null` between sessions; set as soon as // the user toggles a server card on. Drives state-manager lifetime. @@ -226,6 +242,33 @@ function App() { // next switch happens (or when the component unmounts). const [inspectorClient, setInspectorClient] = useState(null); + + // MCP Apps runtime wiring. `sandboxUrl` is the inspector's sandbox-proxy page + // (the trusted outer iframe); `appRendererRef` lets the app handlers push tool + // input/result into the running app and tear it down. The bridge factory wraps + // the active client's underlying SDK client so the running view can call the + // server, and reads the tool's UI resource into the sandbox on handshake. + const appRendererRef = useRef(null); + const { sandboxUrl } = useSandboxUrl({ + baseUrl: + typeof window !== "undefined" + ? window.location.origin + : "http://localhost", + authToken: getAuthToken(), + }); + const sandboxBridgeFactory = useMemo( + () => + createAppBridgeFactory({ + getClient: () => inspectorClient?.getAppRendererClient() ?? null, + readResource: async (uri) => { + if (!inspectorClient) throw new Error("No MCP client connected."); + const invocation = await inspectorClient.readResource(uri); + return invocation.result; + }, + }), + [inspectorClient], + ); + const [managedToolsState, setManagedToolsState] = useState(null); const [managedPromptsState, setManagedPromptsState] = @@ -875,6 +918,88 @@ function App() { setToolCallState(undefined); }, []); + // --- MCP Apps handlers. Unlike onCallTool (which feeds the Tools panel), + // these route the tool input/result into the running app via the renderer's + // imperative handle. --- + + // Surfaces bridge/runtime failures (factory throw — e.g. no client after a + // disconnect — late bridge rejection, or a failed tools/call) that would + // otherwise leave a silently blank app iframe. + const onAppError = useCallback((err: Error) => { + notifications.show({ + title: "MCP App error", + message: err.message, + color: "red", + }); + }, []); + + // Selection is owned by AppsScreen's local state; App.tsx has nothing to do + // on select, but the prop is required so the screen stays prop-driven. + const onSelectApp = useCallback(() => {}, []); + + const onOpenApp = useCallback( + async (name: string, args: Record) => { + if (!inspectorClient) return; + const tool = tools.find((t: Tool) => t.name === name); + if (!tool) return; + // AppsScreen flips `running` -> mounts AppRenderer in the same tick it + // calls this, so the renderer handle isn't wired yet. Yield one microtask + // (after React commits the mount) before pushing input; the renderer then + // buffers it until the view's `initialized` event, releasing input before + // result. + await Promise.resolve(); + void appRendererRef.current?.sendToolInput(args); + try { + // skipOutputValidation: the result is forwarded verbatim to the running + // app (the real consumer), so the host must not reject it on its own + // outputSchema validation — that would deny the app a result the server + // actually returned and legacy hosts render fine. + const invocation = await inspectorClient.callTool( + tool, + args as Record, + undefined, + undefined, + undefined, + { skipOutputValidation: true }, + ); + if (invocation.success && invocation.result) { + void appRendererRef.current?.sendToolResult(invocation.result); + } + // Leniency above keeps the app rendering, but surface the schema + // mismatch so a server developer knows strict MCP clients may refuse + // to render this app. The full validation error is too long for a + // toast, so summarize and link to a modal with the details. + if (invocation.outputValidationError) { + const details = { + toolName: tool.name, + message: invocation.outputValidationError, + }; + notifications.show({ + // Don't auto-dismiss: the message is advisory and the details modal + // is one click away — let the user close it when they've read it. + autoClose: false, + title: "App output doesn't match its schema", + color: "yellow", + message: ( + setOutputValidationDetails(details)} + /> + ), + }); + } + } catch (err) { + // Transport-level failure (the call never returned a result). Surface it + // so the user isn't left staring at a blank/partial app frame. + onAppError(err instanceof Error ? err : new Error(String(err))); + } + }, + [inspectorClient, tools, onAppError], + ); + + const onCloseApp = useCallback(() => { + void appRendererRef.current?.teardown(); + }, []); + const onGetPrompt = useCallback( async (name: string, args: Record) => { if (!inspectorClient) return; @@ -1224,8 +1349,9 @@ function App() { getPromptState={getPromptState} readResourceState={effectiveReadResourceState} currentLogLevel={currentLogLevel} - sandboxPath={STUB_SANDBOX_PATH} - bridgeFactory={stubBridgeFactory} + sandboxPath={sandboxUrl} + bridgeFactory={sandboxBridgeFactory} + appRendererRef={appRendererRef} onToggleTheme={onToggleTheme} onToggleConnection={(id) => { void onToggleConnection(id); @@ -1274,9 +1400,12 @@ function App() { onTogglePinHistory={todoNoop} onClearNetwork={onClearNetwork} onExportNetwork={onExportNetwork} - onSelectApp={todoNoop} - onOpenApp={todoNoop} - onCloseApp={todoNoop} + onSelectApp={onSelectApp} + onOpenApp={(name, args) => { + void onOpenApp(name, args); + }} + onCloseApp={onCloseApp} + onAppError={onAppError} onRefreshApps={onRefreshTools} /> setRemoveTarget(null)} onConfirm={onConfirmRemove} /> + setOutputValidationDetails(null)} + /> ); } diff --git a/clients/web/src/components/elements/AppRenderer/AppRenderer.test.tsx b/clients/web/src/components/elements/AppRenderer/AppRenderer.test.tsx index f7d081e7..e6db5974 100644 --- a/clients/web/src/components/elements/AppRenderer/AppRenderer.test.tsx +++ b/clients/web/src/components/elements/AppRenderer/AppRenderer.test.tsx @@ -1,4 +1,4 @@ -import { createRef } from "react"; +import { createRef, StrictMode } from "react"; import { act } from "@testing-library/react"; import { describe, it, expect, vi } from "vitest"; import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge"; @@ -22,15 +22,33 @@ interface MockBridge { sendToolCancelled: ReturnType; teardownResource: ReturnType; close: ReturnType; + addEventListener: ReturnType; + removeEventListener: ReturnType; + /** Test helper: dispatch a bridge event (e.g. "initialized") to listeners. */ + emit: (event: string, payload?: unknown) => void; } function createMockBridge(): MockBridge { + const listeners: Record void)[]> = {}; return { sendToolInput: vi.fn().mockResolvedValue(undefined), sendToolResult: vi.fn().mockResolvedValue(undefined), sendToolCancelled: vi.fn().mockResolvedValue(undefined), teardownResource: vi.fn().mockResolvedValue({}), close: vi.fn().mockResolvedValue(undefined), + addEventListener: vi.fn((event: string, handler: (p: unknown) => void) => { + (listeners[event] ??= []).push(handler); + }), + removeEventListener: vi.fn( + (event: string, handler: (p: unknown) => void) => { + listeners[event] = (listeners[event] ?? []).filter( + (h) => h !== handler, + ); + }, + ), + emit: (event: string, payload?: unknown) => { + (listeners[event] ?? []).forEach((h) => h(payload)); + }, }; } @@ -90,9 +108,10 @@ describe("AppRenderer", () => { await flushAsync(); expect(factory).toHaveBeenCalledTimes(1); expect(factory.mock.calls[0]?.[0]).toBeInstanceOf(HTMLIFrameElement); + expect(factory.mock.calls[0]?.[1]).toBe(tool); }); - it("forwards sendToolInput through the bridge", async () => { + it("forwards sendToolInput through the bridge once initialized", async () => { const bridge = createMockBridge(); const ref = createRef(); renderWithMantine( @@ -106,13 +125,14 @@ describe("AppRenderer", () => { await flushAsync(); await act(async () => { await ref.current?.sendToolInput({ city: "NYC" }); + bridge.emit("initialized"); }); expect(bridge.sendToolInput).toHaveBeenCalledWith({ arguments: { city: "NYC" }, }); }); - it("forwards sendToolResult through the bridge", async () => { + it("forwards sendToolResult through the bridge once initialized", async () => { const bridge = createMockBridge(); const ref = createRef(); const result: CallToolResult = { @@ -129,10 +149,67 @@ describe("AppRenderer", () => { await flushAsync(); await act(async () => { await ref.current?.sendToolResult(result); + bridge.emit("initialized"); }); expect(bridge.sendToolResult).toHaveBeenCalledWith(result); }); + it("buffers tool input until the view is initialized, then flushes input before result", async () => { + const bridge = createMockBridge(); + const ref = createRef(); + const result: CallToolResult = { content: [{ type: "text", text: "ok" }] }; + renderWithMantine( + asBridge(bridge)} + />, + ); + await flushAsync(); + + // Pushed before `initialized` — must not reach the bridge yet. + await act(async () => { + await ref.current?.sendToolInput({ city: "NYC" }); + await ref.current?.sendToolResult(result); + }); + expect(bridge.sendToolInput).not.toHaveBeenCalled(); + expect(bridge.sendToolResult).not.toHaveBeenCalled(); + + // Initialization releases both, input first. + await act(async () => { + bridge.emit("initialized"); + }); + expect(bridge.sendToolInput).toHaveBeenCalledTimes(1); + expect(bridge.sendToolResult).toHaveBeenCalledTimes(1); + expect(bridge.sendToolInput.mock.invocationCallOrder[0]).toBeLessThan( + bridge.sendToolResult.mock.invocationCallOrder[0], + ); + }); + + it("keeps only the latest buffered input (latest-wins)", async () => { + const bridge = createMockBridge(); + const ref = createRef(); + renderWithMantine( + asBridge(bridge)} + />, + ); + await flushAsync(); + await act(async () => { + await ref.current?.sendToolInput({ city: "NYC" }); + await ref.current?.sendToolInput({ city: "LA" }); + bridge.emit("initialized"); + }); + expect(bridge.sendToolInput).toHaveBeenCalledTimes(1); + expect(bridge.sendToolInput).toHaveBeenCalledWith({ + arguments: { city: "LA" }, + }); + }); + it("forwards sendToolCancelled through the bridge", async () => { const bridge = createMockBridge(); const ref = createRef(); @@ -153,6 +230,42 @@ describe("AppRenderer", () => { }); }); + it("builds a single bridge and does not dispose it under StrictMode double-invoke", async () => { + // React StrictMode runs effects setup→cleanup→setup in dev. The bridge + // (a stateful handshake) must survive that as ONE instance — rebuilding it + // spins up a second transport that double-loads the sandbox and races the + // app's ui/initialize handshake. + const bridge = createMockBridge(); + const factory = vi.fn(() => asBridge(bridge)); + const ref = createRef(); + renderWithMantine( + + + , + ); + await flushAsync(); + + // One build, and the bridge is NOT torn down by the synthetic remount. + expect(factory).toHaveBeenCalledTimes(1); + expect(bridge.teardownResource).not.toHaveBeenCalled(); + expect(bridge.close).not.toHaveBeenCalled(); + + // The reused bridge still delivers buffered input once initialized. + await act(async () => { + await ref.current?.sendToolInput({ city: "NYC" }); + bridge.emit("initialized"); + }); + expect(bridge.sendToolInput).toHaveBeenCalledTimes(1); + expect(bridge.sendToolInput).toHaveBeenCalledWith({ + arguments: { city: "NYC" }, + }); + }); + it("teardown disposes the bridge once and is idempotent", async () => { const bridge = createMockBridge(); const ref = createRef(); diff --git a/clients/web/src/components/elements/AppRenderer/AppRenderer.tsx b/clients/web/src/components/elements/AppRenderer/AppRenderer.tsx index a537c9c7..da23751f 100644 --- a/clients/web/src/components/elements/AppRenderer/AppRenderer.tsx +++ b/clients/web/src/components/elements/AppRenderer/AppRenderer.tsx @@ -1,5 +1,11 @@ import { Box } from "@mantine/core"; -import { useEffect, useImperativeHandle, useRef, type Ref } from "react"; +import { + useCallback, + useEffect, + useImperativeHandle, + useRef, + type Ref, +} from "react"; import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge"; import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js"; @@ -11,6 +17,7 @@ import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js"; */ export type BridgeFactory = ( iframe: HTMLIFrameElement, + tool: Tool, ) => AppBridge | Promise; export interface AppRendererHandle { @@ -47,6 +54,26 @@ async function disposeBridge(bridge: AppBridge): Promise { } } +/** + * Bridge lifecycle (the interlocking refs below): + * + * mount ─▶ build (buildId++) ─▶ factory(iframe,tool) ─async─▶ bridgeRef set + * │ on "initialized" + * ▼ → flushPending + * cleanup ─▶ scheduleDispose() ──microtask──▶ dispose (unless cancelled) + * ▲ │ + * └── re-setup with SAME inputs ─────┘ cancel + REUSE bridge + * + * - `buildId` (monotonic): a bridge resolved from an older build self-disposes. + * - `disposeScheduled`: a dispose is queued (microtask); a synchronous re-setup + * (StrictMode double-invoke, or a transient re-render) cancels it and reuses + * the live bridge instead of rebuilding (rebuild double-loads the sandbox and + * races the app handshake). A re-setup with CHANGED inputs disposes + rebuilds. + * - `lastDeps`: distinguishes "same inputs → reuse" from "changed → rebuild". + * - `initialized`: gates flushing buffered input/result until the view is ready. + * - `pendingInput`/`pendingResult`: latest-wins buffer for host-initiated open. + * - `teardownStarted`: makes the imperative teardown() idempotent vs unmount. + */ export function AppRenderer({ sandboxPath, tool, @@ -56,61 +83,149 @@ export function AppRenderer({ }: AppRendererProps) { const iframeRef = useRef(null); const bridgeRef = useRef(null); + const initializedRef = useRef(false); + const pendingInputRef = useRef | null>(null); + const pendingResultRef = useRef(null); const teardownStartedRef = useRef(false); + // Bridge-lifecycle bookkeeping for the deferred-dispose / reuse dance that + // keeps a single bridge alive across React StrictMode's dev-only + // setup→cleanup→setup double-invoke (see the build effect below). + const buildIdRef = useRef(0); + const disposeScheduledRef = useRef(false); + const lastDepsRef = useRef<{ + bridgeFactory: BridgeFactory; + sandboxPath: string; + tool: Tool; + } | null>(null); const onErrorRef = useRef(onError); useEffect(() => { onErrorRef.current = onError; }); + // Flush buffered tool input/result to the view, but only once the bridge + // exists AND the view has signalled `initialized`. The spec requires tool + // input/result to arrive after initialization, yet a host-initiated open + // (the Open App click) fires before the iframe's app has loaded — so we + // buffer the latest values and release them when the view is ready. Input is + // always sent before result. + const flushPending = useCallback(() => { + const bridge = bridgeRef.current; + if (!bridge || !initializedRef.current) return; + if (pendingInputRef.current !== null) { + const args = pendingInputRef.current; + pendingInputRef.current = null; + void bridge.sendToolInput({ arguments: args }); + } + if (pendingResultRef.current !== null) { + const result = pendingResultRef.current; + pendingResultRef.current = null; + void bridge.sendToolResult(result); + } + }, []); + + // Dispose the live bridge, but deferred to a microtask. React StrictMode runs + // effects setup→cleanup→setup synchronously in dev; deferring lets the + // re-setup cancel the disposal and keep the SAME bridge, instead of tearing + // it down and rebuilding. A rebuild here spins up a second transport that + // re-posts sandbox-resource-ready (the sandbox loads the app twice) and + // races the app's ui/initialize handshake — which is what left apps stuck on + // an empty shell ("handshake timed out") in dev. + const scheduleDispose = useCallback(() => { + disposeScheduledRef.current = true; + queueMicrotask(() => { + if (!disposeScheduledRef.current) return; // cancelled by a re-setup + disposeScheduledRef.current = false; + // Invalidate any in-flight factory so a late-resolving bridge disposes + // itself instead of attaching to a torn-down component. + buildIdRef.current++; + const bridge = bridgeRef.current; + bridgeRef.current = null; + initializedRef.current = false; + lastDepsRef.current = null; + if (bridge) void disposeBridge(bridge); + }); + }, []); + useEffect(() => { const iframe = iframeRef.current; if (!iframe) return; - let cancelled = false; + const prev = lastDepsRef.current; + const sameInputs = + prev !== null && + prev.bridgeFactory === bridgeFactory && + prev.sandboxPath === sandboxPath && + prev.tool === tool; + + // A disposal scheduled by the immediately-preceding cleanup means we are in + // a synchronous re-setup. If the inputs are identical (StrictMode's + // double-invoke, or a transient re-render) keep the live bridge: cancel the + // disposal and re-deliver any buffered input/result to it. + if (disposeScheduledRef.current && sameInputs) { + disposeScheduledRef.current = false; + flushPending(); + return scheduleDispose; + } + + // Otherwise this is a real (re)build. If a disposal was pending (inputs + // changed), run it synchronously before building the replacement. + if (disposeScheduledRef.current) { + disposeScheduledRef.current = false; + buildIdRef.current++; + const old = bridgeRef.current; + bridgeRef.current = null; + initializedRef.current = false; + if (old) void disposeBridge(old); + } + + lastDepsRef.current = { bridgeFactory, sandboxPath, tool }; + const buildId = ++buildIdRef.current; teardownStartedRef.current = false; + initializedRef.current = false; let pending: Promise; try { - pending = Promise.resolve(bridgeFactory(iframe)); + pending = Promise.resolve(bridgeFactory(iframe, tool)); } catch (err) { onErrorRef.current?.(toError(err)); - return () => { - cancelled = true; - }; + return scheduleDispose; } pending .then((bridge) => { - if (cancelled) { + if (buildIdRef.current !== buildId) { void disposeBridge(bridge); return; } bridgeRef.current = bridge; + // Registered before the inner app can finish loading (which only + // happens after the sandbox-resource-ready round-trip the factory + // drives), so the view's `initialized` signal is never missed. + bridge.addEventListener("initialized", () => { + initializedRef.current = true; + flushPending(); + }); + flushPending(); }) .catch((err) => { - if (!cancelled) onErrorRef.current?.(toError(err)); + if (buildIdRef.current === buildId) onErrorRef.current?.(toError(err)); }); - return () => { - cancelled = true; - const bridge = bridgeRef.current; - bridgeRef.current = null; - if (bridge) void disposeBridge(bridge); - }; - }, [bridgeFactory, sandboxPath]); + return scheduleDispose; + }, [bridgeFactory, sandboxPath, tool, flushPending, scheduleDispose]); useImperativeHandle( ref, () => ({ async sendToolInput(args) { - const bridge = bridgeRef.current; - if (!bridge) return; - await bridge.sendToolInput({ arguments: args }); + // Buffered (latest-wins) and released by flushPending once the view is + // initialized — the handle may be invoked before the bridge resolves. + pendingInputRef.current = args; + flushPending(); }, async sendToolResult(result) { - const bridge = bridgeRef.current; - if (!bridge) return; - await bridge.sendToolResult(result); + pendingResultRef.current = result; + flushPending(); }, async sendToolCancelled(reason) { const bridge = bridgeRef.current; @@ -122,12 +237,21 @@ export function AppRenderer({ if (!bridge || teardownStartedRef.current) return; teardownStartedRef.current = true; // Null the ref synchronously so a concurrent unmount cleanup cannot - // see a still-live bridge and dispose it a second time. + // see a still-live bridge and dispose it a second time. Bumping the + // build id makes any in-flight factory self-dispose, and clearing the + // pending-dispose flag/cached deps prevents the deferred dispose from + // acting on an already torn-down bridge. + buildIdRef.current++; + disposeScheduledRef.current = false; + lastDepsRef.current = null; bridgeRef.current = null; + initializedRef.current = false; + pendingInputRef.current = null; + pendingResultRef.current = null; await disposeBridge(bridge); }, }), - [], + [flushPending], ); // The iframe deliberately has no `sandbox` attribute: `sandboxPath` resolves diff --git a/clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.test.ts b/clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.test.ts new file mode 100644 index 00000000..54dd6020 --- /dev/null +++ b/clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.test.ts @@ -0,0 +1,237 @@ +/** + * Tests for createAppBridgeFactory. The ext-apps AppBridge/PostMessageTransport + * are mocked so we can assert the host-side wiring (construction args, the + * sandboxready → resources/read → sendSandboxResourceReady round-trip, openLink + * handling, connect) without a real iframe/postMessage environment. The real + * end-to-end iframe round-trip is covered by the AppsScreen Storybook play test. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { + Tool, + ReadResourceResult, +} from "@modelcontextprotocol/sdk/types.js"; +import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; + +// --- ext-apps mock ------------------------------------------------------- +const bridgeInstances: MockBridge[] = []; + +interface MockBridge { + ctorArgs: unknown[]; + listeners: Record void)[]>; + addEventListener: ReturnType; + sendSandboxResourceReady: ReturnType; + connect: ReturnType; + onopenlink?: (params: { url: string }) => Promise<{ isError?: boolean }>; + emit: (event: string, payload?: unknown) => void; +} + +vi.mock("@modelcontextprotocol/ext-apps/app-bridge", () => { + class AppBridge { + ctorArgs: unknown[]; + listeners: Record void)[]> = {}; + addEventListener = vi.fn((event: string, handler: (p: unknown) => void) => { + (this.listeners[event] ??= []).push(handler); + }); + sendSandboxResourceReady = vi.fn().mockResolvedValue(undefined); + connect = vi.fn().mockResolvedValue(undefined); + onopenlink?: (params: { url: string }) => Promise<{ isError?: boolean }>; + emit = (event: string, payload?: unknown) => { + (this.listeners[event] ?? []).forEach((h) => h(payload)); + }; + constructor(...args: unknown[]) { + this.ctorArgs = args; + bridgeInstances.push(this as unknown as MockBridge); + } + } + class PostMessageTransport { + target: unknown; + source: unknown; + constructor(target: unknown, source: unknown) { + this.target = target; + this.source = source; + } + } + return { + AppBridge, + PostMessageTransport, + getToolUiResourceUri: (tool: Partial) => + (tool._meta as { ui?: { resourceUri?: string } } | undefined)?.ui + ?.resourceUri, + }; +}); + +import { createAppBridgeFactory } from "./createAppBridgeFactory"; + +const tool: Tool = { + name: "weather_app", + inputSchema: { type: "object" }, + _meta: { ui: { resourceUri: "ui://weather/app.html" } }, +}; + +function makeIframe(hasWindow = true): HTMLIFrameElement { + return { + contentWindow: hasWindow ? ({} as Window) : null, + } as unknown as HTMLIFrameElement; +} + +const fakeClient = { name: "sdk-client" } as unknown as Client; + +function uiResource( + text: string | undefined, + meta?: Record, +): ReadResourceResult { + return { + contents: [ + { + uri: "ui://weather/app.html", + ...(text === undefined ? {} : { text }), + ...(meta ? { _meta: meta } : {}), + }, + ], + } as ReadResourceResult; +} + +async function flush(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +describe("createAppBridgeFactory", () => { + beforeEach(() => { + bridgeInstances.length = 0; + }); + + it("throws when no client is connected", async () => { + const factory = createAppBridgeFactory({ + getClient: () => null, + readResource: vi.fn(), + }); + await expect(factory(makeIframe(), tool)).rejects.toThrow( + /no connected MCP client/, + ); + }); + + it("throws when the iframe has no contentWindow", async () => { + const factory = createAppBridgeFactory({ + getClient: () => fakeClient, + readResource: vi.fn(), + }); + await expect(factory(makeIframe(false), tool)).rejects.toThrow(/no window/); + }); + + it("constructs the bridge with the client, host info, capabilities and theme, then connects", async () => { + // Theme is read from the DOM (Mantine's resolved color-scheme attribute). + document.documentElement.setAttribute("data-mantine-color-scheme", "dark"); + try { + const factory = createAppBridgeFactory({ + getClient: () => fakeClient, + readResource: vi.fn().mockResolvedValue(uiResource("

hi

")), + }); + await factory(makeIframe(), tool); + expect(bridgeInstances).toHaveLength(1); + const bridge = bridgeInstances[0]; + expect(bridge.ctorArgs[0]).toBe(fakeClient); + expect(bridge.ctorArgs[1]).toMatchObject({ name: "MCP Inspector" }); + expect(bridge.ctorArgs[2]).toMatchObject({ serverTools: {} }); + expect(bridge.ctorArgs[3]).toEqual({ hostContext: { theme: "dark" } }); + expect(bridge.connect).toHaveBeenCalledTimes(1); + } finally { + document.documentElement.removeAttribute("data-mantine-color-scheme"); + } + }); + + it("on sandboxready, reads the UI resource and pushes html + meta to the sandbox", async () => { + const readResource = vi.fn().mockResolvedValue( + uiResource("

weather

", { + permissions: { geolocation: {} }, + csp: { connectSrc: ["https://api.example.com"] }, + }), + ); + const factory = createAppBridgeFactory({ + getClient: () => fakeClient, + readResource, + }); + await factory(makeIframe(), tool); + const bridge = bridgeInstances[0]; + + bridge.emit("sandboxready"); + await flush(); + + expect(readResource).toHaveBeenCalledWith("ui://weather/app.html"); + expect(bridge.sendSandboxResourceReady).toHaveBeenCalledWith({ + html: "

weather

", + permissions: { geolocation: {} }, + csp: { connectSrc: ["https://api.example.com"] }, + }); + }); + + it("does not push when the tool has no UI resource uri", async () => { + const readResource = vi.fn(); + const factory = createAppBridgeFactory({ + getClient: () => fakeClient, + readResource, + }); + await factory(makeIframe(), { + name: "plain", + inputSchema: { type: "object" }, + }); + bridgeInstances[0].emit("sandboxready"); + await flush(); + expect(readResource).not.toHaveBeenCalled(); + expect(bridgeInstances[0].sendSandboxResourceReady).not.toHaveBeenCalled(); + }); + + it("swallows a resources/read failure without rejecting", async () => { + const readResource = vi.fn().mockRejectedValue(new Error("read boom")); + const factory = createAppBridgeFactory({ + getClient: () => fakeClient, + readResource, + }); + await factory(makeIframe(), tool); + const bridge = bridgeInstances[0]; + bridge.emit("sandboxready"); + await flush(); + expect(bridge.sendSandboxResourceReady).not.toHaveBeenCalled(); + }); + + it("swallows a UI resource that has no text content", async () => { + const readResource = vi.fn().mockResolvedValue(uiResource(undefined)); + const factory = createAppBridgeFactory({ + getClient: () => fakeClient, + readResource, + }); + await factory(makeIframe(), tool); + const bridge = bridgeInstances[0]; + bridge.emit("sandboxready"); + await flush(); + expect(bridge.sendSandboxResourceReady).not.toHaveBeenCalled(); + }); + + it("opens http(s) links in a new tab and reports non-http as error", async () => { + const open = vi + .spyOn(window, "open") + .mockImplementation(() => null as unknown as Window); + const factory = createAppBridgeFactory({ + getClient: () => fakeClient, + readResource: vi.fn().mockResolvedValue(uiResource("

x

")), + }); + await factory(makeIframe(), tool); + const bridge = bridgeInstances[0]; + + await expect( + bridge.onopenlink!({ url: "https://example.com" }), + ).resolves.toEqual({ isError: false }); + expect(open).toHaveBeenCalledWith( + "https://example.com", + "_blank", + "noopener,noreferrer", + ); + + await expect( + bridge.onopenlink!({ url: "javascript:alert(1)" }), + ).resolves.toEqual({ isError: true }); + + open.mockRestore(); + }); +}); diff --git a/clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.ts b/clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.ts new file mode 100644 index 00000000..079d2665 --- /dev/null +++ b/clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.ts @@ -0,0 +1,165 @@ +import { + AppBridge, + PostMessageTransport, + getToolUiResourceUri, +} from "@modelcontextprotocol/ext-apps/app-bridge"; +import type { + McpUiHostCapabilities, + McpUiResourceMeta, +} from "@modelcontextprotocol/ext-apps/app-bridge"; +import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import type { + Implementation, + ReadResourceResult, +} from "@modelcontextprotocol/sdk/types.js"; +import type { BridgeFactory } from "./AppRenderer"; + +/** + * Host identity advertised to MCP Apps during the bridge handshake. Static — + * the value is informational (shown by some apps), not a protocol version. + */ +export const HOST_INFO: Implementation = { + name: "MCP Inspector", + version: "2.0.0", +}; + +/** + * Capabilities the inspector host offers a running MCP App. Constructed WITH an + * MCP client (see {@link createAppBridgeFactory}), so the bridge auto-forwards + * tools/resources/prompts to the view; we only declare the host-side features + * we actually back: external links (handled below), tool/resource list-change + * forwarding, and logging passthrough. + */ +export const HOST_CAPABILITIES: McpUiHostCapabilities = { + openLinks: {}, + serverTools: { listChanged: true }, + serverResources: { listChanged: true }, + logging: {}, +}; + +export interface AppBridgeFactoryDeps { + /** The connected SDK client to back the bridge, or null when disconnected. */ + getClient: () => Client | null; + /** Reads a UI resource (resources/read) and returns the SDK result. */ + readResource: (uri: string) => Promise; +} + +/** + * Resolve the host theme from the DOM at bridge-build time. Mantine writes the + * resolved color scheme to ``. Reading it here + * (rather than capturing React state in the factory deps) keeps the factory's + * identity stable across theme toggles — the AppRenderer treats a new factory + * identity as "rebuild the bridge", which would reload a running app's iframe on + * every theme flip. The theme is read once per bridge build (the value at open + * time); pushing live theme updates to an already-open app would need an + * AppBridge.setHostContext follow-up. + * + * The attribute is only ever `"light"` or `"dark"` — Mantine resolves + * `defaultColorScheme="auto"` to the system value before paint and never writes + * `"auto"` here, so no `auto` branch is needed. The matchMedia fallback only + * covers the attribute being absent (e.g. a hydration race). + */ +function currentTheme(): "light" | "dark" { + if (typeof document !== "undefined") { + const attr = document.documentElement.getAttribute( + "data-mantine-color-scheme", + ); + if (attr === "dark" || attr === "light") return attr; + } + if ( + typeof window !== "undefined" && + window.matchMedia?.("(prefers-color-scheme: dark)").matches + ) { + return "dark"; + } + return "light"; +} + +/** First text content block of a UI resource, plus its `_meta` (sandbox hints). */ +function extractHtmlAndMeta(result: ReadResourceResult): { + html: string; + meta: McpUiResourceMeta | undefined; +} { + for (const content of result.contents) { + const text = (content as { text?: unknown }).text; + if (typeof text === "string") { + return { + html: text, + meta: content._meta as McpUiResourceMeta | undefined, + }; + } + } + throw new Error("UI resource has no text (HTML) content"); +} + +/** + * Builds the {@link BridgeFactory} the AppRenderer uses to bring a sandbox + * iframe to life. For each mounted iframe + tool it: + * + * 1. constructs a host-side {@link AppBridge} over the SDK client (so the view + * can call tools/resources/prompts directly), + * 2. on the sandbox proxy's `sandboxready` signal, reads the tool's UI + * resource and pushes its HTML + sandbox/permissions into the inner iframe, + * 3. handles `openLinks` by opening http(s) URLs in a new tab, + * 4. connects a {@link PostMessageTransport} to the iframe and returns the + * live bridge. + * + * Host-initiated tool input/result are pushed separately through the renderer's + * imperative handle (see `AppRenderer`), gated on the view's `initialized` + * event. The factory throws when no client is connected; AppRenderer routes + * that to its `onError` so the user sees a clear failure instead of a blank + * frame. + */ +export function createAppBridgeFactory( + deps: AppBridgeFactoryDeps, +): BridgeFactory { + return async (iframe, tool) => { + const client = deps.getClient(); + if (!client) { + throw new Error("Cannot render MCP App: no connected MCP client."); + } + const targetWindow = iframe.contentWindow; + if (!targetWindow) { + throw new Error("Cannot render MCP App: sandbox iframe has no window."); + } + + const bridge = new AppBridge(client, HOST_INFO, HOST_CAPABILITIES, { + hostContext: { theme: currentTheme() }, + }); + + // The double-iframe proxy posts `sandboxready` once it can receive content. + // Read the tool's UI resource and hand its HTML (plus any sandbox/permission + // hints from the resource _meta) to the inner sandboxed iframe. Swallow + // failures: a read error leaves an empty (but live) app frame rather than + // tearing down the bridge mid-handshake. + bridge.addEventListener("sandboxready", () => { + void (async () => { + try { + const uri = getToolUiResourceUri(tool); + if (!uri) return; + const result = await deps.readResource(uri); + const { html, meta } = extractHtmlAndMeta(result); + await bridge.sendSandboxResourceReady({ + html, + permissions: meta?.permissions, + csp: meta?.csp, + }); + } catch { + /* read/post failed (or bridge closed) — leave the frame empty */ + } + })(); + }); + + bridge.onopenlink = async ({ url }) => { + if (/^https?:\/\//i.test(url)) { + window.open(url, "_blank", "noopener,noreferrer"); + return { isError: false }; + } + return { isError: true }; + }; + + const transport = new PostMessageTransport(targetWindow, targetWindow); + await bridge.connect(transport); + return bridge; + }; +} diff --git a/clients/web/src/components/groups/OutputValidationModal/OutputValidationModal.stories.tsx b/clients/web/src/components/groups/OutputValidationModal/OutputValidationModal.stories.tsx new file mode 100644 index 00000000..0942c7dd --- /dev/null +++ b/clients/web/src/components/groups/OutputValidationModal/OutputValidationModal.stories.tsx @@ -0,0 +1,61 @@ +import { AppShell } from "@mantine/core"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, fn, within } from "storybook/test"; +import { + OutputValidationModal, + type OutputValidationModalProps, +} from "./OutputValidationModal"; + +const SAMPLE_MESSAGE = [ + "data/samples/0 must NOT have additional properties", + "data/samples/1 must NOT have additional properties", + "data/samples/2 must NOT have additional properties", + "data/samples/3 must NOT have additional properties", + "data/samples/4 must NOT have additional properties", +].join(", "); + +function InteractiveRender(args: OutputValidationModalProps) { + return ( + + + + + + ); +} + +const meta: Meta = { + title: "Groups/OutputValidationModal", + component: OutputValidationModal, + parameters: { layout: "fullscreen" }, + render: InteractiveRender, + args: { + opened: true, + onClose: fn(), + toolName: "open_pattern_editor", + message: SAMPLE_MESSAGE, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + play: async ({ canvasElement }) => { + // Mantine renders the modal in a portal at document.body. + const body = within(canvasElement.ownerDocument.body); + await body.findByText("Output schema validation"); + expect(body.getByText(/open_pattern_editor/)).toBeInTheDocument(); + const details = body.getByLabelText( + "Validation details", + ) as HTMLTextAreaElement; + expect(details.value).toContain( + "data/samples/0 must NOT have additional properties", + ); + expect(details.readOnly).toBe(true); + }, +}; + +export const WithoutToolName: Story = { + args: { toolName: undefined }, +}; diff --git a/clients/web/src/components/groups/OutputValidationModal/OutputValidationModal.test.tsx b/clients/web/src/components/groups/OutputValidationModal/OutputValidationModal.test.tsx new file mode 100644 index 00000000..8681ec00 --- /dev/null +++ b/clients/web/src/components/groups/OutputValidationModal/OutputValidationModal.test.tsx @@ -0,0 +1,58 @@ +import { describe, it, expect, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { renderWithMantine, screen } from "../../../test/renderWithMantine"; +import { OutputValidationModal } from "./OutputValidationModal"; + +const MESSAGE = + "data/samples/0 must NOT have additional properties, data/samples/1 must NOT have additional properties"; + +describe("OutputValidationModal", () => { + it("renders the tool name and the full validation message in a read-only field", () => { + renderWithMantine( + , + ); + expect(screen.getByText("Output schema validation")).toBeInTheDocument(); + expect(screen.getByText(/open_pattern_editor/)).toBeInTheDocument(); + const details = screen.getByLabelText( + "Validation details", + ) as HTMLTextAreaElement; + expect(details.value).toBe(MESSAGE); + expect(details.readOnly).toBe(true); + }); + + it("falls back to a generic message when no tool name is provided", () => { + renderWithMantine( + , + ); + expect( + screen.getByText(/does not match the declared outputSchema/i), + ).toBeInTheDocument(); + }); + + it("calls onClose when the close button is clicked", async () => { + const onClose = vi.fn(); + renderWithMantine( + , + ); + await userEvent.click(screen.getByRole("button", { name: "Close" })); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("renders nothing visible when closed", () => { + renderWithMantine( + , + ); + expect( + screen.queryByText("Output schema validation"), + ).not.toBeInTheDocument(); + }); +}); diff --git a/clients/web/src/components/groups/OutputValidationModal/OutputValidationModal.tsx b/clients/web/src/components/groups/OutputValidationModal/OutputValidationModal.tsx new file mode 100644 index 00000000..5cc287fa --- /dev/null +++ b/clients/web/src/components/groups/OutputValidationModal/OutputValidationModal.tsx @@ -0,0 +1,64 @@ +import { + CloseButton, + Group, + Modal, + Stack, + Text, + Textarea, + Title, +} from "@mantine/core"; + +export interface OutputValidationModalProps { + opened: boolean; + onClose: () => void; + /** The app tool whose result failed output-schema validation. */ + toolName?: string; + /** The full validation error message (one issue per line). */ + message?: string; +} + +/** + * Shows the full output-schema validation error for an MCP App tool result. + * The inspector still renders the app (the result is forwarded verbatim to the + * view), but the result violates the tool's declared `outputSchema`, so strict + * MCP clients may refuse to render it — this modal surfaces the details a + * server developer needs to fix it. Opened from the warning toast. + */ +export function OutputValidationModal({ + opened, + onClose, + toolName, + message, +}: OutputValidationModalProps) { + return ( + + + + + Output schema validation + + + + + {toolName + ? `"${toolName}" returned structuredContent that does not match its declared outputSchema. The inspector renders the app anyway, but strict MCP clients may refuse to display it.` + : "The tool result's structuredContent does not match the declared outputSchema. The inspector renders the app anyway, but strict MCP clients may refuse to display it."} + +