diff --git a/clients/web/README.md b/clients/web/README.md index 1bff70e3a..329317e4c 100644 --- a/clients/web/README.md +++ b/clients/web/README.md @@ -54,6 +54,23 @@ Components live under `src/components/` in four layers, smallest to largest: Every screen and element has a `*.stories.tsx` (see [Storybook](#storybook)). Styling follows the Mantine-first rules in [`AGENTS.md`](../../AGENTS.md) — theme variants and component props over CSS, `--inspector-*` tokens over raw colors. +## MCP Apps screen automation contract + +The Apps screen exposes a small, stable set of `data-testid` / `data-*` attributes so an automated driver (deep-link auto-open, CI review harness) can `waitForSelector` on a deterministic signal instead of sleeping. Treat these as a public contract — drivers depend on them staying stable: + +| Attribute | Where | Meaning | +| --- | --- | --- | +| `data-testid="apps-form"` | Apps content card | The container that carries the status/error attributes below. | +| `data-app-status` | on `apps-form` | Renderer lifecycle: `idle` (nothing running) → `loading` (bridge building / `ui/initialize` in flight) → `ready` (view fired `notifications/initialized`) → `error` (bridge factory threw/rejected). Poll for `ready`. | +| `data-app-error` | on `apps-form` | The failure reason string when `data-app-status="error"` (e.g. no connected client); absent otherwise. | +| `data-testid="apps-error"` | error panel | Rendered below the frame when the app fails to load (factory throw/reject); shows the reason so the failure isn't a silent blank frame. | +| `data-testid="open-app"` | Open App button | Launches the selected app. | +| `data-testid="apps-stage"` | Stage-partial button | Snapshots the current form values for progressive-render testing. | +| `data-testid="apps-messages"` | messages panel | `ui/message` submissions from the running view. | +| `data-testid="apps-logs"` | app-logs panel | `notifications/message` log entries (default-expanded). | + +The renderer lifecycle itself is `AppRendererStatus` (`loading` | `ready` | `error`) reported via `AppRenderer`'s `onAppStatusChange`; the screen maps it to `data-app-status`. Resource-read failures (malformed/404 UI resource) are surfaced as a toast via the bridge factory's `onResourceError`; because the app never reaches `ready` in that case, a driver times out on `data-app-status` and reads the toast. + ## Theme (`src/theme/`) Each customized Mantine component has a `Theme.ts` file (`Button.ts`, `Text.ts`, …, ~21 total) exporting a `Theme` constant; the barrel `index.ts` re-exports them and `theme.ts` assembles the `MantineProvider` theme. Theme files hold app-wide defaults and **variants** (flat CSS-in-JS); only pseudo-selectors, nested child selectors, keyframes, and native-HTML styling belong in `App.css`. Element components import from `@mantine/core` (never from `theme/`) — the theme layer is applied transparently by the provider. diff --git a/clients/web/server/sandbox-controller.ts b/clients/web/server/sandbox-controller.ts index f007b4054..92419c4e0 100644 --- a/clients/web/server/sandbox-controller.ts +++ b/clients/web/server/sandbox-controller.ts @@ -47,6 +47,18 @@ export function createSandboxController( let server: Server | null = null; let sandboxUrl: string | null = null; + // Defense-in-depth for the proxy page itself. Only `frame-ancestors` is set + // here — fetch directives (`default-src`, `connect-src`, etc.) are + // deliberately omitted because a `srcdoc` iframe clones its embedder's CSP + // policy container: any fetch directive on this header would be inherited by + // the inner app document and, since multiple CSPs intersect, would override + // the per-app `connect-src`/`img-src` allowlists the host bakes into the + // wrapped HTML (see src/lib/sandbox-csp.ts). The opaque-origin sandbox on + // the inner frame is the structural boundary; `frame-ancestors` ensures the + // proxy can only be embedded by the local inspector itself. + const SANDBOX_PROXY_CSP = + "frame-ancestors http://127.0.0.1:* http://localhost:*"; + let sandboxHtml: string; try { const sandboxHtmlPath = join(__dirname, "../static/sandbox_proxy.html"); @@ -91,6 +103,7 @@ export function createSandboxController( "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store, no-cache, must-revalidate", Pragma: "no-cache", + "Content-Security-Policy": SANDBOX_PROXY_CSP, }); res.end(sandboxHtml); }); diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 48295bcf5..255d71e31 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -708,6 +708,19 @@ function App() { const invocation = await inspectorClient.readResource(uri); return invocation.result; }, + // The bridge's sandboxready handler reads + posts the UI resource + // inside a detached async block; without this hook a 404 / malformed + // resource is console.error-only and the user stares at a blank + // frame. Surface it as a toast. The renderer separately drives + // `data-app-status` so an automated driver can time out on + // never-reaching-"ready" and read the toast. + onResourceError: (err) => { + notifications.show({ + title: "App resource failed to load", + message: err.message, + color: "red", + }); + }, }), [inspectorClient], ); diff --git a/clients/web/src/components/elements/AppRenderer/AppRenderer.stories.tsx b/clients/web/src/components/elements/AppRenderer/AppRenderer.stories.tsx index 2ee718b3e..2ff8c0eb9 100644 --- a/clients/web/src/components/elements/AppRenderer/AppRenderer.stories.tsx +++ b/clients/web/src/components/elements/AppRenderer/AppRenderer.stories.tsx @@ -20,6 +20,9 @@ function createMockBridge(): AppBridge { sendToolInput: async () => {}, sendToolResult: async () => {}, sendToolCancelled: async () => {}, + sendHostContextChange: async () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, teardownResource: async () => ({}), close: async () => {}, } as unknown as AppBridge; diff --git a/clients/web/src/components/elements/AppRenderer/AppRenderer.test.tsx b/clients/web/src/components/elements/AppRenderer/AppRenderer.test.tsx index 8a10a82dc..440886328 100644 --- a/clients/web/src/components/elements/AppRenderer/AppRenderer.test.tsx +++ b/clients/web/src/components/elements/AppRenderer/AppRenderer.test.tsx @@ -1,6 +1,6 @@ import { createRef, StrictMode } from "react"; import { act } from "@testing-library/react"; -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge"; import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js"; import { renderWithMantine, screen } from "../../../test/renderWithMantine"; @@ -18,12 +18,21 @@ const tool: Tool = { interface MockBridge { sendToolInput: ReturnType; + sendToolInputPartial: ReturnType; sendToolResult: ReturnType; sendToolCancelled: ReturnType; + sendHostContextChange: ReturnType; teardownResource: ReturnType; close: ReturnType; addEventListener: ReturnType; removeEventListener: ReturnType; + onrequestdisplaymode?: (params: { + mode: "inline" | "fullscreen" | "pip"; + }) => Promise<{ mode: "inline" | "fullscreen" | "pip" }>; + onmessage?: (params: { + role: "user"; + content: unknown[]; + }) => Promise>; /** Test helper: dispatch a bridge event (e.g. "initialized") to listeners. */ emit: (event: string, payload?: unknown) => void; } @@ -32,8 +41,10 @@ function createMockBridge(): MockBridge { const listeners: Record void)[]> = {}; return { sendToolInput: vi.fn().mockResolvedValue(undefined), + sendToolInputPartial: vi.fn().mockResolvedValue(undefined), sendToolResult: vi.fn().mockResolvedValue(undefined), sendToolCancelled: vi.fn().mockResolvedValue(undefined), + sendHostContextChange: vi.fn().mockResolvedValue(undefined), teardownResource: vi.fn().mockResolvedValue({}), close: vi.fn().mockResolvedValue(undefined), addEventListener: vi.fn((event: string, handler: (p: unknown) => void) => { @@ -230,6 +241,559 @@ describe("AppRenderer", () => { }); }); + it("forwards view size-changed notifications to onSizeChange", async () => { + const bridge = createMockBridge(); + const onSizeChange = vi.fn(); + renderWithMantine( + asBridge(bridge)} + onSizeChange={onSizeChange} + />, + ); + await flushAsync(); + await act(async () => { + bridge.emit("sizechange", { width: 480, height: 600 }); + }); + expect(onSizeChange).toHaveBeenCalledWith({ width: 480, height: 600 }); + }); + + it("does not throw on size-changed when no onSizeChange is provided", async () => { + const bridge = createMockBridge(); + renderWithMantine( + asBridge(bridge)} + />, + ); + await flushAsync(); + await act(async () => { + bridge.emit("sizechange", { height: 320 }); + }); + expect(screen.getByTitle("Cohort App")).toBeInTheDocument(); + }); + + it("routes ui/request-display-mode to onRequestDisplayMode and returns the applied mode", async () => { + const bridge = createMockBridge(); + const onRequestDisplayMode = vi + .fn<(m: "inline" | "fullscreen" | "pip") => "inline" | "fullscreen">() + .mockReturnValue("fullscreen"); + renderWithMantine( + asBridge(bridge)} + displayMode="inline" + onRequestDisplayMode={onRequestDisplayMode} + />, + ); + await flushAsync(); + await expect( + bridge.onrequestdisplaymode?.({ mode: "fullscreen" }), + ).resolves.toEqual({ mode: "fullscreen" }); + expect(onRequestDisplayMode).toHaveBeenCalledWith("fullscreen"); + }); + + it("declines ui/request-display-mode by returning the current displayMode when no handler is provided", async () => { + const bridge = createMockBridge(); + renderWithMantine( + asBridge(bridge)} + displayMode="fullscreen" + />, + ); + await flushAsync(); + await expect( + bridge.onrequestdisplaymode?.({ mode: "pip" }), + ).resolves.toEqual({ mode: "fullscreen" }); + }); + + it("declines ui/request-display-mode with inline when neither a handler nor displayMode is set", async () => { + const bridge = createMockBridge(); + renderWithMantine( + asBridge(bridge)} + />, + ); + await flushAsync(); + await expect( + bridge.onrequestdisplaymode?.({ mode: "pip" }), + ).resolves.toEqual({ mode: "inline" }); + }); + + it("replays partialInputs in order before the complete tool-input on initialize", async () => { + const bridge = createMockBridge(); + const ref = createRef(); + renderWithMantine( + asBridge(bridge)} + partialInputs={[{ city: "N" }, { city: "New" }]} + />, + ); + await flushAsync(); + await act(async () => { + await ref.current?.sendToolInput({ city: "New York" }); + bridge.emit("initialized"); + }); + expect(bridge.sendToolInputPartial).toHaveBeenCalledTimes(2); + expect(bridge.sendToolInputPartial).toHaveBeenNthCalledWith(1, { + arguments: { city: "N" }, + }); + expect(bridge.sendToolInputPartial).toHaveBeenNthCalledWith(2, { + arguments: { city: "New" }, + }); + expect( + bridge.sendToolInputPartial.mock.invocationCallOrder[1], + ).toBeLessThan(bridge.sendToolInput.mock.invocationCallOrder[0]); + }); + + it("sends no tool-input-partial when partialInputs is omitted", async () => { + const bridge = createMockBridge(); + const ref = createRef(); + renderWithMantine( + asBridge(bridge)} + />, + ); + await flushAsync(); + await act(async () => { + await ref.current?.sendToolInput({ city: "NYC" }); + bridge.emit("initialized"); + }); + expect(bridge.sendToolInputPartial).not.toHaveBeenCalled(); + }); + + it("forwards MCP log notifications to onLog", async () => { + const bridge = createMockBridge(); + const onLog = vi.fn(); + renderWithMantine( + asBridge(bridge)} + onLog={onLog} + />, + ); + await flushAsync(); + await act(async () => { + bridge.emit("loggingmessage", { level: "warning", data: "disk full" }); + }); + expect(onLog).toHaveBeenCalledWith({ level: "warning", data: "disk full" }); + }); + + it("does not throw on a log notification when no onLog is provided", async () => { + const bridge = createMockBridge(); + renderWithMantine( + asBridge(bridge)} + />, + ); + await flushAsync(); + await act(async () => { + bridge.emit("loggingmessage", { level: "info", data: "hi" }); + }); + expect(screen.getByTitle("Cohort App")).toBeInTheDocument(); + }); + + it("routes ui/message to onMessage and returns the spec-required empty result", async () => { + const bridge = createMockBridge(); + const onMessage = vi.fn(); + renderWithMantine( + asBridge(bridge)} + onMessage={onMessage} + />, + ); + await flushAsync(); + const params = { + role: "user" as const, + content: [{ type: "text", text: "hello host" }], + }; + await expect(bridge.onmessage?.(params)).resolves.toEqual({}); + expect(onMessage).toHaveBeenCalledWith(params); + }); + + it("declines ui/message with isError when no onMessage handler is provided", async () => { + const bridge = createMockBridge(); + renderWithMantine( + asBridge(bridge)} + />, + ); + await flushAsync(); + await expect( + bridge.onmessage?.({ role: "user", content: [] }), + ).resolves.toEqual({ isError: true }); + }); + + it("pushes a displayMode change to the running view via host-context-changed", async () => { + const bridge = createMockBridge(); + // Stable factory identity so the rerender reuses the live bridge instead of + // rebuilding (which would reset `initialized` and gate the push). + const factory: BridgeFactory = () => asBridge(bridge); + const { rerender } = renderWithMantine( + , + ); + await flushAsync(); + await act(async () => bridge.emit("initialized")); + bridge.sendHostContextChange.mockClear(); + rerender( + , + ); + await flushAsync(); + expect(bridge.sendHostContextChange).toHaveBeenCalledWith({ + displayMode: "fullscreen", + }); + }); + + it("does not push a displayMode change before the view is initialized", async () => { + const bridge = createMockBridge(); + const factory: BridgeFactory = () => asBridge(bridge); + const { rerender } = renderWithMantine( + , + ); + await flushAsync(); + bridge.sendHostContextChange.mockClear(); + // No `initialized` emitted yet — the push must be gated. + rerender( + , + ); + await flushAsync(); + expect(bridge.sendHostContextChange).not.toHaveBeenCalledWith({ + displayMode: "fullscreen", + }); + }); + + it("pushes a live theme flip (with resolved styles) to the running bridge via host-context-changed", async () => { + const bridge = createMockBridge(); + // Stub the computed design tokens so currentStyles() resolves a non-empty + // McpUiHostStyles — exercises the theme observer's styles-included path. + const realGetComputedStyle = window.getComputedStyle; + const getComputedStyleSpy = vi + .spyOn(window, "getComputedStyle") + .mockImplementation((el: Element, pseudo?: string | null) => { + const decl = realGetComputedStyle.call(window, el, pseudo ?? undefined); + return { + ...decl, + getPropertyValue: (prop: string) => + prop === "--mantine-color-body" + ? "#101113" + : decl.getPropertyValue(prop), + } as CSSStyleDeclaration; + }); + try { + renderWithMantine( + asBridge(bridge)} + />, + ); + await flushAsync(); + // The theme observer is gated on the view's `initialized` signal, so + // complete the handshake before flipping. + await act(async () => bridge.emit("initialized")); + // Ignore any seeding from Mantine's own mount-time write — assert only the + // flip we trigger below. + bridge.sendHostContextChange.mockClear(); + + await act(async () => { + document.documentElement.setAttribute( + "data-mantine-color-scheme", + "dark", + ); + // MutationObserver callbacks are delivered on a microtask. + await Promise.resolve(); + }); + expect(bridge.sendHostContextChange).toHaveBeenCalledWith( + expect.objectContaining({ + theme: "dark", + styles: expect.objectContaining({ + variables: expect.objectContaining({ + "--color-background-primary": "#101113", + }), + }), + }), + ); + } finally { + getComputedStyleSpy.mockRestore(); + document.documentElement.removeAttribute("data-mantine-color-scheme"); + } + }); + + it("does not push a theme flip before the view is initialized", async () => { + const bridge = createMockBridge(); + renderWithMantine( + asBridge(bridge)} + />, + ); + await flushAsync(); + // No `initialized` emitted — the theme observer is gated, like the + // container and displayMode pushes, so a pre-handshake flip is dropped. + bridge.sendHostContextChange.mockClear(); + await act(async () => { + document.documentElement.setAttribute( + "data-mantine-color-scheme", + "dark", + ); + await Promise.resolve(); + }); + expect(bridge.sendHostContextChange).not.toHaveBeenCalled(); + document.documentElement.removeAttribute("data-mantine-color-scheme"); + }); + + it("stops observing theme changes after the renderer unmounts", async () => { + const bridge = createMockBridge(); + const { unmount } = renderWithMantine( + asBridge(bridge)} + />, + ); + await flushAsync(); + await act(async () => { + unmount(); + await Promise.resolve(); + await Promise.resolve(); + }); + bridge.sendHostContextChange.mockClear(); + + await act(async () => { + document.documentElement.setAttribute( + "data-mantine-color-scheme", + "dark", + ); + await Promise.resolve(); + }); + expect(bridge.sendHostContextChange).not.toHaveBeenCalled(); + document.documentElement.removeAttribute("data-mantine-color-scheme"); + }); + + describe("containerDimensions", () => { + // Stub ResizeObserver so tests can drive the callback directly: capture the + // callback + the observed element so its getBoundingClientRect can be + // patched before each fire. + let resizeCallback: (() => void) | undefined; + let observedEl: HTMLElement | undefined; + let originalResizeObserver: typeof ResizeObserver | undefined; + + beforeEach(() => { + resizeCallback = undefined; + observedEl = undefined; + originalResizeObserver = globalThis.ResizeObserver; + globalThis.ResizeObserver = class { + constructor(cb: () => void) { + resizeCallback = cb; + } + observe(el: Element) { + observedEl = el as HTMLElement; + } + unobserve() {} + disconnect() { + resizeCallback = undefined; + } + } as unknown as typeof ResizeObserver; + }); + afterEach(() => { + if (originalResizeObserver) { + globalThis.ResizeObserver = originalResizeObserver; + } else { + delete (globalThis as { ResizeObserver?: unknown }).ResizeObserver; + } + }); + + function stubSize(el: HTMLElement, width: number, height: number) { + vi.spyOn(el, "getBoundingClientRect").mockReturnValue({ + width, + height, + top: 0, + left: 0, + right: width, + bottom: height, + x: 0, + y: 0, + toJSON: () => ({}), + } as DOMRect); + } + + it("pushes containerDimensions on initialize when the container has a layout box", async () => { + const bridge = createMockBridge(); + const container = document.createElement("div"); + stubSize(container, 320, 200); + renderWithMantine( + asBridge(bridge)} + containerRef={{ current: container }} + />, + ); + await flushAsync(); + await act(async () => bridge.emit("initialized")); + expect(bridge.sendHostContextChange).toHaveBeenCalledWith({ + containerDimensions: { width: 320, height: 200 }, + }); + }); + + it("does not push containerDimensions on initialize when the container has no layout box", async () => { + const bridge = createMockBridge(); + const container = document.createElement("div"); + stubSize(container, 0, 0); + renderWithMantine( + asBridge(bridge)} + containerRef={{ current: container }} + />, + ); + await flushAsync(); + // A mount-time theme write may have already pushed a {theme} change; + // clear so we assert only about the initialize-time containerDimensions. + bridge.sendHostContextChange.mockClear(); + await act(async () => bridge.emit("initialized")); + expect(bridge.sendHostContextChange).not.toHaveBeenCalled(); + }); + + it("does not push containerDimensions on resize before the view is initialized", async () => { + const bridge = createMockBridge(); + const container = document.createElement("div"); + renderWithMantine( + asBridge(bridge)} + containerRef={{ current: container }} + />, + ); + await flushAsync(); + bridge.sendHostContextChange.mockClear(); + stubSize(container, 640, 480); + await act(async () => resizeCallback?.()); + expect(bridge.sendHostContextChange).not.toHaveBeenCalled(); + }); + + it("pushes containerDimensions on resize once initialized; skips a 0×0 box and a value-equal repeat", async () => { + const bridge = createMockBridge(); + const container = document.createElement("div"); + renderWithMantine( + asBridge(bridge)} + containerRef={{ current: container }} + />, + ); + await flushAsync(); + await act(async () => bridge.emit("initialized")); + bridge.sendHostContextChange.mockClear(); + + stubSize(container, 640, 480); + await act(async () => resizeCallback?.()); + expect(bridge.sendHostContextChange).toHaveBeenCalledWith({ + containerDimensions: { width: 640, height: 480 }, + }); + + bridge.sendHostContextChange.mockClear(); + stubSize(container, 640, 480); + await act(async () => resizeCallback?.()); + expect(bridge.sendHostContextChange).not.toHaveBeenCalled(); + + stubSize(container, 0, 0); + await act(async () => resizeCallback?.()); + expect(bridge.sendHostContextChange).not.toHaveBeenCalled(); + }); + + it("observes the host-supplied containerRef element instead of the iframe when provided", async () => { + const bridge = createMockBridge(); + const container = document.createElement("div"); + renderWithMantine( + asBridge(bridge)} + containerRef={{ current: container }} + />, + ); + await flushAsync(); + expect(observedEl).toBe(container); + }); + + it("falls back to observing the iframe when no containerRef is provided", async () => { + const bridge = createMockBridge(); + renderWithMantine( + asBridge(bridge)} + />, + ); + await flushAsync(); + expect(observedEl).toBeInstanceOf(HTMLIFrameElement); + }); + + it("disconnects the ResizeObserver on unmount", async () => { + const bridge = createMockBridge(); + const { unmount } = renderWithMantine( + asBridge(bridge)} + />, + ); + await flushAsync(); + await act(async () => bridge.emit("initialized")); + await act(async () => { + unmount(); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(resizeCallback).toBeUndefined(); + }); + }); + 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 @@ -402,6 +966,59 @@ describe("AppRenderer", () => { expect(bridge.close).toHaveBeenCalledTimes(1); }); + it("transitions loading -> ready across the view's initialized signal", async () => { + const bridge = createMockBridge(); + const onAppStatusChange = vi.fn(); + renderWithMantine( + asBridge(bridge)} + onAppStatusChange={onAppStatusChange} + />, + ); + await flushAsync(); + // The build starts with "loading" before the bridge resolves. + expect(onAppStatusChange).toHaveBeenCalledWith("loading"); + expect(onAppStatusChange).not.toHaveBeenCalledWith("ready"); + await act(async () => bridge.emit("initialized")); + expect(onAppStatusChange).toHaveBeenLastCalledWith("ready"); + }); + + it("reports status 'error' when the bridge factory throws synchronously", async () => { + const onAppStatusChange = vi.fn(); + const factory: BridgeFactory = () => { + throw new Error("sync boom"); + }; + renderWithMantine( + , + ); + await flushAsync(); + expect(onAppStatusChange).toHaveBeenCalledWith("loading"); + expect(onAppStatusChange).toHaveBeenLastCalledWith("error"); + }); + + it("reports status 'error' when the bridge factory rejects", async () => { + const onAppStatusChange = vi.fn(); + const factory: BridgeFactory = () => + Promise.reject(new Error("async boom")); + renderWithMantine( + , + ); + await flushAsync(); + expect(onAppStatusChange).toHaveBeenLastCalledWith("error"); + }); + it("calls onError when the bridge factory throws", async () => { const onError = vi.fn(); const factory: BridgeFactory = () => { diff --git a/clients/web/src/components/elements/AppRenderer/AppRenderer.tsx b/clients/web/src/components/elements/AppRenderer/AppRenderer.tsx index 54d9f684a..8e061c687 100644 --- a/clients/web/src/components/elements/AppRenderer/AppRenderer.tsx +++ b/clients/web/src/components/elements/AppRenderer/AppRenderer.tsx @@ -5,9 +5,24 @@ import { useImperativeHandle, useRef, type Ref, + type RefObject, } from "react"; -import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge"; -import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js"; +import type { + AppBridge, + AppBridgeEventMap, + McpUiDisplayMode, + McpUiMessageRequest, +} from "@modelcontextprotocol/ext-apps/app-bridge"; +import type { + CallToolResult, + LoggingMessageNotification, + Tool, +} from "@modelcontextprotocol/sdk/types.js"; +import { + currentStyles, + currentTheme, + measureContainerDimensions, +} from "./hostContext"; /** * Constructs the `AppBridge` for a freshly mounted sandbox iframe. Wrap with @@ -27,11 +42,74 @@ export interface AppRendererHandle { teardown(): Promise; } +/** + * High-level lifecycle of a running app, surfaced so a host (or an automated + * driver polling a `data-app-status` attribute) can wait for the right moment: + * `loading` while the bridge is being built and the view's `ui/initialize` + * handshake is in flight; `ready` once the view has fired + * `notifications/initialized`; `error` when the bridge factory throws or + * rejects (no live view to wait on). + */ +export type AppRendererStatus = "loading" | "ready" | "error"; + export interface AppRendererProps { sandboxPath: string; tool: Tool; bridgeFactory: BridgeFactory; onError?: (err: Error) => void; + /** + * Reports the renderer's high-level lifecycle (see {@link AppRendererStatus}). + * Fires `loading` at the start of every (re)build, `ready` when the view + * signals `initialized`, and `error` on a factory throw/rejection. + */ + onAppStatusChange?: (status: AppRendererStatus) => void; + /** + * Called when the running view reports a new rendered content size via + * `ui/notifications/size-changed` (typically driven by its `ResizeObserver`). + * Width and height (px) are both optional. The host uses this to resize the + * iframe's container so the widget is neither clipped nor padded with dead + * space. + */ + onSizeChange?: (size: AppBridgeEventMap["sizechange"]) => void; + /** + * Current host display mode for the app frame. Pushed to the running view + * via `host-context-changed` whenever it changes (e.g. Maximize/Restore), so + * an app can adapt its layout to inline vs fullscreen. + */ + displayMode?: McpUiDisplayMode; + /** + * Handles a view-originated `ui/request-display-mode`. Return the mode the + * host actually applied — the spec lets the host decline an unsupported mode + * by returning its current one. + */ + onRequestDisplayMode?: (requested: McpUiDisplayMode) => McpUiDisplayMode; + /** + * Called when the running view submits a user-role message via + * `ui/message`. The renderer returns the spec-required empty result on + * the host's behalf, so the callback is fire-and-forget. + */ + onMessage?: (params: McpUiMessageRequest["params"]) => void; + /** + * Called for each MCP log notification (`notifications/message`) the + * running view emits. Backs the advertised `logging` host capability. + */ + onLog?: (params: LoggingMessageNotification["params"]) => void; + /** + * Ordered tool-input fragments to replay via + * `ui/notifications/tool-input-partial` BEFORE the complete `tool-input`, + * exercising widgets that render progressively. Captured at bridge-build + * time (see `pendingPartialsRef`) so prop churn never rebuilds the iframe. + * Nothing is sent when omitted/empty. + */ + partialInputs?: Record[]; + /** + * The host-controlled box the app renders within, used to derive + * `hostContext.containerDimensions`. This MUST be an element whose size is + * driven by the host's layout (window resize, sidebar toggle, maximize) and + * NOT by the view's own `size-changed` reports — otherwise the two signals + * couple into a feedback loop. Falls back to the iframe element when omitted. + */ + containerRef?: RefObject; ref?: Ref; } @@ -79,11 +157,20 @@ export function AppRenderer({ tool, bridgeFactory, onError, + onAppStatusChange, + onSizeChange, + displayMode, + onRequestDisplayMode, + onMessage, + onLog, + partialInputs, + containerRef, ref, }: AppRendererProps) { const iframeRef = useRef(null); const bridgeRef = useRef(null); const initializedRef = useRef(false); + const pendingPartialsRef = useRef[]>([]); const pendingInputRef = useRef | null>(null); const pendingResultRef = useRef(null); const teardownStartedRef = useRef(false); @@ -98,8 +185,22 @@ export function AppRenderer({ tool: Tool; } | null>(null); const onErrorRef = useRef(onError); + const onAppStatusChangeRef = useRef(onAppStatusChange); + const onSizeChangeRef = useRef(onSizeChange); + const displayModeRef = useRef(displayMode); + const onRequestDisplayModeRef = useRef(onRequestDisplayMode); + const onMessageRef = useRef(onMessage); + const onLogRef = useRef(onLog); + const partialInputsRef = useRef(partialInputs); useEffect(() => { onErrorRef.current = onError; + onAppStatusChangeRef.current = onAppStatusChange; + onSizeChangeRef.current = onSizeChange; + displayModeRef.current = displayMode; + onRequestDisplayModeRef.current = onRequestDisplayMode; + onMessageRef.current = onMessage; + onLogRef.current = onLog; + partialInputsRef.current = partialInputs; }); // Flush buffered tool input/result to the view, but only once the bridge @@ -111,6 +212,12 @@ export function AppRenderer({ const flushPending = useCallback(() => { const bridge = bridgeRef.current; if (!bridge || !initializedRef.current) return; + // Partial-input fragments first, in staged order, BEFORE the complete + // tool-input — the spec requires partials to precede the final input. + for (const args of pendingPartialsRef.current) { + void bridge.sendToolInputPartial({ arguments: args }); + } + pendingPartialsRef.current = []; if (pendingInputRef.current !== null) { const args = pendingInputRef.current; pendingInputRef.current = null; @@ -142,6 +249,7 @@ export function AppRenderer({ bridgeRef.current = null; initializedRef.current = false; lastDepsRef.current = null; + pendingPartialsRef.current = []; if (bridge) void disposeBridge(bridge); }); }, []); @@ -189,11 +297,18 @@ export function AppRenderer({ const buildId = ++buildIdRef.current; teardownStartedRef.current = false; initializedRef.current = false; + onAppStatusChangeRef.current?.("loading"); + // Snapshot the staged partial-input fragments for THIS bridge build (read + // via the ref so the prop is not a dep — adding/removing fragments must not + // rebuild the iframe). The StrictMode reuse path above returned before + // reaching here, so a reused bridge keeps the queue it was built with. + pendingPartialsRef.current = [...(partialInputsRef.current ?? [])]; let pending: Promise; try { pending = Promise.resolve(bridgeFactory(iframe, tool)); } catch (err) { + onAppStatusChangeRef.current?.("error"); onErrorRef.current?.(toError(err)); return scheduleDispose; } @@ -210,16 +325,151 @@ export function AppRenderer({ // drives), so the view's `initialized` signal is never missed. bridge.addEventListener("initialized", () => { initializedRef.current = true; + onAppStatusChangeRef.current?.("ready"); + // The factory already seeded theme/styles/displayMode into the + // handshake hostContext; the observers below cover any subsequent + // changes. Only containerDimensions can plausibly differ between + // bridge construction and initialization (layout settles), so push + // that one field now via the SDK's partial-change notification. + const container = containerRef?.current ?? iframeRef.current; + const containerDimensions = container + ? measureContainerDimensions(container) + : undefined; + if (containerDimensions) { + void bridge.sendHostContextChange({ containerDimensions }); + } flushPending(); }); + // Forward the view's content-size reports (ui/notifications/size-changed) + // so the host can resize the iframe container to fit the rendered widget. + bridge.addEventListener("sizechange", (size) => { + onSizeChangeRef.current?.(size); + }); + // Forward the view's MCP log notifications so the host can honor the + // advertised `logging` capability instead of dropping them. + bridge.addEventListener("loggingmessage", (params) => { + onLogRef.current?.(params); + }); + // Handle ui/request-display-mode: let the host (AppsScreen) decide what + // mode to actually apply and return that. With no handler the request is + // declined by returning the current host-side mode. + bridge.onrequestdisplaymode = async ({ mode }) => { + const handler = onRequestDisplayModeRef.current; + const applied = handler + ? handler(mode) + : (displayModeRef.current ?? "inline"); + return { mode: applied }; + }; + // Handle ui/message: surface the submitted content and return the + // spec-required empty result. With no handler the submission is + // declined by returning isError. + bridge.onmessage = async (params) => { + const handler = onMessageRef.current; + if (!handler) return { isError: true }; + handler(params); + return {}; + }; flushPending(); }) .catch((err) => { - if (buildIdRef.current === buildId) onErrorRef.current?.(toError(err)); + if (buildIdRef.current !== buildId) return; + onAppStatusChangeRef.current?.("error"); + onErrorRef.current?.(toError(err)); }); return scheduleDispose; - }, [bridgeFactory, sandboxPath, tool, flushPending, scheduleDispose]); + // `containerRef` is listed for exhaustive-deps completeness, but a change to + // its identity does NOT force a rebuild: the `sameInputs` check above + // ignores it, so a new ref object hits the StrictMode reuse path (the + // `initialized` handler reads `containerRef?.current` lazily, so the live + // ref is always used regardless). The other deps are the real rebuild keys. + }, [ + bridgeFactory, + sandboxPath, + tool, + containerRef, + flushPending, + scheduleDispose, + ]); + + // Push live host-context changes to the running view as discrete partial + // updates via AppBridge.sendHostContextChange (the SDK's + // ui/notifications/host-context-changed sender). Each effect observes one + // host signal and sends only the field(s) it owns, so the view receives the + // spec's "only changed fields" partials without any host-side snapshot + // bookkeeping. Reading `bridgeRef.current` at callback time (not capturing a + // bridge) means the observers always target the live bridge, even though it + // resolves asynchronously after these effects run. + + // Theme + styles: Mantine writes the resolved scheme to + // ``; observe that attribute and forward + // changes through the live bridge. Gated on the view's `initialized` signal + // — like the container and displayMode pushes below — so a theme flip in the + // window between bridge construction and the handshake doesn't race + // `ui/initialize`. Nothing is lost by waiting: the factory seeds the + // construction-time theme/styles into the handshake hostContext, and the + // first post-init flip carries the current value. + useEffect(() => { + /* v8 ignore next 5 -- SSR/non-DOM guard: MutationObserver and document are + always defined under happy-dom, so this early return is unreachable in + the test environment. */ + if ( + typeof MutationObserver === "undefined" || + typeof document === "undefined" + ) { + return; + } + const observer = new MutationObserver(() => { + if (!initializedRef.current) return; + const styles = currentStyles(); + void bridgeRef.current?.sendHostContextChange({ + theme: currentTheme(), + ...(styles ? { styles } : {}), + }); + }); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["data-mantine-color-scheme"], + }); + return () => observer.disconnect(); + }, []); + + // Container size: observes the host-controlled container (or the iframe as a + // fallback) — NOT an element whose height is driven by the view's own + // size-changed reports, which would couple the two signals into a feedback + // loop. Gated on the view's `initialized` signal so the notification only + // fires once the handshake is complete; a 0×0 (not-yet-laid-out) measurement + // and a value-equal repeat are both skipped. + useEffect(() => { + const target = containerRef?.current ?? iframeRef.current; + /* v8 ignore next -- SSR/non-DOM guard: ResizeObserver is stubbed/defined + and the iframe (or containerRef) target is always present after mount in + tests, so neither disjunct is reachable here. */ + if (typeof ResizeObserver === "undefined" || !target) return; + let last: { width: number; height: number } | undefined; + const observer = new ResizeObserver(() => { + if (!initializedRef.current) return; + const next = measureContainerDimensions(target); + if (!next) return; + if (last && last.width === next.width && last.height === next.height) { + return; + } + last = next; + void bridgeRef.current?.sendHostContextChange({ + containerDimensions: next, + }); + }); + observer.observe(target); + return () => observer.disconnect(); + }, [containerRef]); + + // Display mode: pushes whenever the prop changes (Maximize/Restore). Gated on + // `initialized` for the same reason as the other host-context pushes. + useEffect(() => { + if (displayMode === undefined) return; + if (!initializedRef.current) return; + void bridgeRef.current?.sendHostContextChange({ displayMode }); + }, [displayMode]); useImperativeHandle( ref, diff --git a/clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.test.ts b/clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.test.ts index 54dd6020a..45aa3cde9 100644 --- a/clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.test.ts +++ b/clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.test.ts @@ -6,7 +6,7 @@ * end-to-end iframe round-trip is covered by the AppsScreen Storybook play test. */ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import type { Tool, ReadResourceResult, @@ -23,6 +23,12 @@ interface MockBridge { sendSandboxResourceReady: ReturnType; connect: ReturnType; onopenlink?: (params: { url: string }) => Promise<{ isError?: boolean }>; + ondownloadfile?: (params: { + contents: ( + | { type: "resource"; resource: Record } + | { type: "resource_link"; uri: string } + )[]; + }) => Promise<{ isError?: boolean }>; emit: (event: string, payload?: unknown) => void; } @@ -61,7 +67,10 @@ vi.mock("@modelcontextprotocol/ext-apps/app-bridge", () => { }; }); -import { createAppBridgeFactory } from "./createAppBridgeFactory"; +import { + createAppBridgeFactory, + HOST_CAPABILITIES, +} from "./createAppBridgeFactory"; const tool: Tool = { name: "weather_app", @@ -134,18 +143,27 @@ describe("createAppBridgeFactory", () => { 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" } }); + // hostContext is the full snapshot: theme (from the DOM attribute), + // the inline display mode, and the host's available display modes. + // styles/containerDimensions are omitted for the bare test iframe. + expect(bridge.ctorArgs[3]).toMatchObject({ + hostContext: { + theme: "dark", + displayMode: "inline", + availableDisplayModes: ["inline", "fullscreen"], + }, + }); 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 () => { + it("on sandboxready, reads the UI resource, wraps the html with the per-app CSP, and echoes the approved sandbox config", async () => { const readResource = vi.fn().mockResolvedValue( uiResource("

weather

", { permissions: { geolocation: {} }, - csp: { connectSrc: ["https://api.example.com"] }, + csp: { connectDomains: ["https://api.example.com"] }, }), ); const factory = createAppBridgeFactory({ @@ -159,11 +177,79 @@ describe("createAppBridgeFactory", () => { await flush(); expect(readResource).toHaveBeenCalledWith("ui://weather/app.html"); - expect(bridge.sendSandboxResourceReady).toHaveBeenCalledWith({ - html: "

weather

", + // The html is wrapped in a host-authored shell whose first child is + // the CSP ; the untrusted markup lands inside . The per-app + // connect-src the app requested is baked into the policy. + const call = bridge.sendSandboxResourceReady.mock.calls[0][0] as { + html: string; + permissions: unknown; + csp?: unknown; + }; + expect(call.permissions).toEqual({ geolocation: {} }); + // csp is NOT sent inline — it is enforced via the wrapped and echoed + // through hostCapabilities.sandbox instead. + expect(call.csp).toBeUndefined(); + expect(call.html).toContain('http-equiv="Content-Security-Policy"'); + expect(call.html).toContain("connect-src https://api.example.com"); + expect(call.html).toContain("

weather

"); + + // The approved (post-filter) csp + permissions are echoed on the bridge's + // hostCapabilities so the view sees what was granted. + const caps = bridge.ctorArgs[2] as { + sandbox?: { permissions?: unknown; csp?: unknown }; + }; + expect(caps.sandbox).toEqual({ permissions: { geolocation: {} }, - csp: { connectSrc: ["https://api.example.com"] }, + csp: { connectDomains: ["https://api.example.com"] }, + }); + }); + + it("does not mutate the shared HOST_CAPABILITIES when echoing the approved sandbox", async () => { + // The factory builds a per-app copy ({ ...HOST_CAPABILITIES }) so the + // sandbox echo never leaks across apps/renders. Lock that in: after a + // sandboxready run that sets hostCapabilities.sandbox, the shared constant + // must stay untouched. + const readResource = vi.fn().mockResolvedValue( + uiResource("

x

", { + csp: { connectDomains: ["https://api.example.com"] }, + }), + ); + const factory = createAppBridgeFactory({ + getClient: () => fakeClient, + readResource, }); + await factory(makeIframe(), tool); + bridgeInstances[0].emit("sandboxready"); + await flush(); + expect(HOST_CAPABILITIES.sandbox).toBeUndefined(); + }); + + it("drops an unsafe app-supplied CSP source before wrapping", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const readResource = vi.fn().mockResolvedValue( + uiResource("

x

", { + // The second source injects a directive terminator — it must be dropped. + csp: { + connectDomains: ["https://ok.example.com", "evil; script-src *"], + }, + }), + ); + const factory = createAppBridgeFactory({ + getClient: () => fakeClient, + readResource, + }); + await factory(makeIframe(), tool); + const bridge = bridgeInstances[0]; + bridge.emit("sandboxready"); + await flush(); + + const call = bridge.sendSandboxResourceReady.mock.calls[0][0] as { + html: string; + }; + expect(call.html).toContain("connect-src https://ok.example.com;"); + expect(call.html).not.toContain("evil"); + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); }); it("does not push when the tool has no UI resource uri", async () => { @@ -182,21 +268,72 @@ describe("createAppBridgeFactory", () => { expect(bridgeInstances[0].sendSandboxResourceReady).not.toHaveBeenCalled(); }); - it("swallows a resources/read failure without rejecting", async () => { + it("reports a resources/read failure via onResourceError and console.error without rejecting", async () => { + const err = vi.spyOn(console, "error").mockImplementation(() => {}); + const onResourceError = vi.fn(); const readResource = vi.fn().mockRejectedValue(new Error("read boom")); const factory = createAppBridgeFactory({ getClient: () => fakeClient, readResource, + onResourceError, }); await factory(makeIframe(), tool); const bridge = bridgeInstances[0]; bridge.emit("sandboxready"); await flush(); expect(bridge.sendSandboxResourceReady).not.toHaveBeenCalled(); + expect(onResourceError).toHaveBeenCalledWith( + expect.objectContaining({ message: "read boom" }), + ); + expect(err).toHaveBeenCalled(); + err.mockRestore(); }); - it("swallows a UI resource that has no text content", async () => { + it("reports a UI resource that has no text content via onResourceError", async () => { + const err = vi.spyOn(console, "error").mockImplementation(() => {}); + const onResourceError = vi.fn(); const readResource = vi.fn().mockResolvedValue(uiResource(undefined)); + const factory = createAppBridgeFactory({ + getClient: () => fakeClient, + readResource, + onResourceError, + }); + await factory(makeIframe(), tool); + const bridge = bridgeInstances[0]; + bridge.emit("sandboxready"); + await flush(); + expect(bridge.sendSandboxResourceReady).not.toHaveBeenCalled(); + expect(onResourceError).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("no text"), + }), + ); + err.mockRestore(); + }); + + it("wraps a non-Error rejection into an Error before reporting", async () => { + const err = vi.spyOn(console, "error").mockImplementation(() => {}); + const onResourceError = vi.fn(); + const readResource = vi.fn().mockRejectedValue("plain string boom"); + const factory = createAppBridgeFactory({ + getClient: () => fakeClient, + readResource, + onResourceError, + }); + await factory(makeIframe(), tool); + const bridge = bridgeInstances[0]; + bridge.emit("sandboxready"); + await flush(); + expect(onResourceError).toHaveBeenCalledWith( + expect.objectContaining({ message: "plain string boom" }), + ); + expect(onResourceError.mock.calls[0][0]).toBeInstanceOf(Error); + err.mockRestore(); + }); + + it("does not throw on read failure when onResourceError is omitted", async () => { + const err = vi.spyOn(console, "error").mockImplementation(() => {}); + const readResource = vi.fn().mockRejectedValue(new Error("read boom")); const factory = createAppBridgeFactory({ getClient: () => fakeClient, readResource, @@ -206,6 +343,8 @@ describe("createAppBridgeFactory", () => { bridge.emit("sandboxready"); await flush(); expect(bridge.sendSandboxResourceReady).not.toHaveBeenCalled(); + expect(err).toHaveBeenCalled(); + err.mockRestore(); }); it("opens http(s) links in a new tab and reports non-http as error", async () => { @@ -234,4 +373,350 @@ describe("createAppBridgeFactory", () => { open.mockRestore(); }); + + it("advertises the downloadFile host capability", async () => { + const factory = createAppBridgeFactory({ + getClient: () => fakeClient, + readResource: vi.fn().mockResolvedValue(uiResource("

x

")), + }); + await factory(makeIframe(), tool); + expect(bridgeInstances[0].ctorArgs[2]).toMatchObject({ downloadFile: {} }); + }); + + describe("ondownloadfile", () => { + // happy-dom does not implement window.confirm, so stub it (rather than + // spyOn an absent function). Returns the installed mock for assertions. + function stubConfirm(approved: boolean): ReturnType { + const confirm = vi.fn().mockReturnValue(approved); + vi.stubGlobal("confirm", confirm); + return confirm; + } + + async function buildBridge(): Promise { + const factory = createAppBridgeFactory({ + getClient: () => fakeClient, + readResource: vi.fn().mockResolvedValue(uiResource("

x

")), + }); + await factory(makeIframe(), tool); + return bridgeInstances[0]; + } + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("downloads an inline text resource after confirmation", async () => { + vi.useFakeTimers(); + const confirm = stubConfirm(true); + const createUrl = vi + .spyOn(URL, "createObjectURL") + .mockReturnValue("blob:fake"); + const revokeUrl = vi + .spyOn(URL, "revokeObjectURL") + .mockImplementation(() => undefined); + const click = vi + .spyOn(HTMLAnchorElement.prototype, "click") + .mockImplementation(() => undefined); + + const bridge = await buildBridge(); + await expect( + bridge.ondownloadfile!({ + contents: [ + { + type: "resource", + resource: { + uri: "file:///report.csv", + mimeType: "text/csv", + text: "a,b\n1,2", + }, + }, + ], + }), + ).resolves.toEqual({ isError: false }); + + expect(confirm).toHaveBeenCalledTimes(1); + expect(createUrl).toHaveBeenCalledTimes(1); + expect(click).toHaveBeenCalledTimes(1); + const clickedAnchor = click.mock.instances[0] as HTMLAnchorElement; + expect(clickedAnchor.download).toBe("report.csv"); + // Revoke is deferred so the browser can read the blob before it's freed. + expect(revokeUrl).not.toHaveBeenCalled(); + vi.runAllTimers(); + expect(revokeUrl).toHaveBeenCalledWith("blob:fake"); + vi.useRealTimers(); + }); + + it("falls back to a 'download' filename when the URI has no usable tail", async () => { + vi.useFakeTimers(); + stubConfirm(true); + vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:fake"); + vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => undefined); + const click = vi + .spyOn(HTMLAnchorElement.prototype, "click") + .mockImplementation(() => undefined); + const bridge = await buildBridge(); + await bridge.ondownloadfile!({ + contents: [ + { + type: "resource", + resource: { + uri: "file:///path/", + mimeType: "text/plain", + text: "", + }, + }, + ], + }); + expect((click.mock.instances[0] as HTMLAnchorElement).download).toBe( + "download", + ); + vi.runAllTimers(); + vi.useRealTimers(); + }); + + it("warns and reports partial success when some items in a batch are skipped", async () => { + stubConfirm(true); + vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:fake"); + vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => undefined); + vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation( + () => undefined, + ); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const bridge = await buildBridge(); + await expect( + bridge.ondownloadfile!({ + contents: [ + { + type: "resource", + resource: { uri: "file:///ok.txt", text: "ok" }, + }, + { type: "resource_link", uri: "javascript:alert(1)" }, + ], + }), + ).resolves.toEqual({ isError: false }); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("1 of 2 download item(s) skipped"), + expect.arrayContaining(["javascript:alert(1)"]), + ); + warn.mockRestore(); + }); + + it("decodes a base64 blob resource", async () => { + stubConfirm(true); + const createUrl = vi + .spyOn(URL, "createObjectURL") + .mockReturnValue("blob:fake"); + vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => undefined); + vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation( + () => undefined, + ); + + const bridge = await buildBridge(); + await expect( + bridge.ondownloadfile!({ + contents: [ + { + type: "resource", + resource: { + uri: "file:///logo.png", + mimeType: "image/png", + blob: btoa("PNGDATA"), + }, + }, + ], + }), + ).resolves.toEqual({ isError: false }); + + const blob = createUrl.mock.calls[0][0] as Blob; + expect(blob.type).toBe("image/png"); + expect(await blob.text()).toBe("PNGDATA"); + }); + + it("opens an http(s) resource link in a new tab", async () => { + stubConfirm(true); + const open = vi + .spyOn(window, "open") + .mockImplementation(() => null as unknown as Window); + + const bridge = await buildBridge(); + await expect( + bridge.ondownloadfile!({ + contents: [ + { type: "resource_link", uri: "https://example.com/a.pdf" }, + ], + }), + ).resolves.toEqual({ isError: false }); + + expect(open).toHaveBeenCalledWith( + "https://example.com/a.pdf", + "_blank", + "noopener,noreferrer", + ); + }); + + it("returns isError when the user declines the confirmation", async () => { + stubConfirm(false); + const createUrl = vi.spyOn(URL, "createObjectURL"); + + const bridge = await buildBridge(); + await expect( + bridge.ondownloadfile!({ + contents: [ + { type: "resource", resource: { uri: "file:///x.txt", text: "x" } }, + ], + }), + ).resolves.toEqual({ isError: true }); + expect(createUrl).not.toHaveBeenCalled(); + }); + + it("returns isError for an empty contents array without confirming", async () => { + const confirm = stubConfirm(true); + const bridge = await buildBridge(); + await expect(bridge.ondownloadfile!({ contents: [] })).resolves.toEqual({ + isError: true, + }); + expect(confirm).not.toHaveBeenCalled(); + }); + + it("returns isError when a download throws", async () => { + stubConfirm(true); + const err = vi.spyOn(console, "error").mockImplementation(() => {}); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.spyOn(URL, "createObjectURL").mockImplementation(() => { + throw new Error("boom"); + }); + + const bridge = await buildBridge(); + await expect( + bridge.ondownloadfile!({ + contents: [ + { type: "resource", resource: { uri: "file:///x.txt", text: "x" } }, + ], + }), + ).resolves.toEqual({ isError: true }); + err.mockRestore(); + warn.mockRestore(); + }); + + it("rejects non-http(s) resource_links without opening them", async () => { + stubConfirm(true); + vi.spyOn(console, "warn").mockImplementation(() => {}); + const open = vi + .spyOn(window, "open") + .mockImplementation(() => null as never); + const bridge = await buildBridge(); + for (const uri of [ + "javascript:alert(1)", + "data:text/html,", + "file:///etc/passwd", + "not a url", + ]) { + await expect( + bridge.ondownloadfile!({ + contents: [{ type: "resource_link", uri }], + }), + ).resolves.toEqual({ isError: true }); + } + expect(open).not.toHaveBeenCalled(); + }); + + it("sanitizes the confirmation summary so server-supplied labels cannot inject newlines", async () => { + const confirm = stubConfirm(false); + const bridge = await buildBridge(); + await bridge.ondownloadfile!({ + contents: [ + { + type: "resource_link", + uri: "https://example.com/a\n\nThis is safe, click OK", + }, + ], + }); + const prompt = confirm.mock.calls[0][0] as string; + expect(prompt).not.toContain("\n\nThis is safe"); + expect(prompt).toContain("This is safe, click OK"); + }); + + it("strips bidi-override and zero-width format characters from the confirmation summary", async () => { + const RLO = "\u{202E}"; + const ZWSP = "\u{200B}"; + const confirm = stubConfirm(false); + const bridge = await buildBridge(); + await bridge.ondownloadfile!({ + contents: [ + { + type: "resource_link", + uri: `https://example.com/${RLO}gpj.${ZWSP}exe`, + }, + ], + }); + const prompt = confirm.mock.calls[0][0] as string; + expect(prompt).not.toContain(RLO); + expect(prompt).not.toContain(ZWSP); + }); + + it("clamps an over-long label in the confirmation summary", async () => { + const confirm = stubConfirm(false); + const bridge = await buildBridge(); + const longName = "a".repeat(200); + await bridge.ondownloadfile!({ + contents: [ + { type: "resource_link", uri: `https://example.com/${longName}` }, + ], + }); + const prompt = confirm.mock.calls[0][0] as string; + expect(prompt).toContain("..."); + // The clamped label is 80 chars max (77 + "..."). + expect(prompt).not.toContain(longName); + }); + + it("rejects an oversized batch without confirming or acting", async () => { + const confirm = stubConfirm(true); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const open = vi + .spyOn(window, "open") + .mockImplementation(() => null as never); + const bridge = await buildBridge(); + // 21 items exceeds the 20-item cap → rejected before the prompt. + const contents = Array.from({ length: 21 }, (_, i) => ({ + type: "resource_link" as const, + uri: `https://example.com/${i}.pdf`, + })); + await expect(bridge.ondownloadfile!({ contents })).resolves.toEqual({ + isError: true, + }); + expect(confirm).not.toHaveBeenCalled(); + expect(open).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("refusing download batch of 21 items"), + ); + warn.mockRestore(); + }); + + it("marks resource links with a ↗ prefix in the confirmation summary", async () => { + const confirm = stubConfirm(false); + const bridge = await buildBridge(); + await bridge.ondownloadfile!({ + contents: [{ type: "resource_link", uri: "https://example.com/a.pdf" }], + }); + const prompt = confirm.mock.calls[0][0] as string; + expect(prompt).toContain("↗ https://example.com/a.pdf"); + }); + + it("skips an embedded resource with neither text nor blob (no 'undefined' file)", async () => { + stubConfirm(true); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const createUrl = vi.spyOn(URL, "createObjectURL"); + const bridge = await buildBridge(); + // Untrusted payload: a resource object carrying neither field. It must be + // skipped, not written as a file containing the literal text "undefined". + await expect( + bridge.ondownloadfile!({ + contents: [{ type: "resource", resource: { uri: "file:///x" } }], + }), + ).resolves.toEqual({ isError: true }); + expect(createUrl).not.toHaveBeenCalled(); + warn.mockRestore(); + }); + }); }); diff --git a/clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.ts b/clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.ts index 079d26650..4b59b4f61 100644 --- a/clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.ts +++ b/clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.ts @@ -4,14 +4,28 @@ import { getToolUiResourceUri, } from "@modelcontextprotocol/ext-apps/app-bridge"; import type { + McpUiDisplayMode, McpUiHostCapabilities, McpUiResourceMeta, } from "@modelcontextprotocol/ext-apps/app-bridge"; import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; import type { + EmbeddedResource, Implementation, ReadResourceResult, + ResourceLink, } from "@modelcontextprotocol/sdk/types.js"; +import { + approveCspSources, + buildSandboxCspPolicy, + wrapSandboxedHtml, +} from "../../../lib/sandbox-csp"; +import { + downloadBlob, + fileNameFromUri, + isHttpUrl, +} from "../../../lib/downloadFile"; +import { snapshotHostContext } from "./hostContext"; import type { BridgeFactory } from "./AppRenderer"; /** @@ -27,52 +41,39 @@ export const HOST_INFO: Implementation = { * 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. + * we actually back: external links and file downloads (both handled below), + * tool/resource list-change forwarding, and logging passthrough. */ export const HOST_CAPABILITIES: McpUiHostCapabilities = { openLinks: {}, + downloadFile: {}, serverTools: { listChanged: true }, serverResources: { listChanged: true }, logging: {}, }; +/** + * Display modes the inspector host supports, advertised in the handshake + * hostContext (`availableDisplayModes`). AppsScreen renders an app either + * inline within its layout card or maximized to fill the screen, so only those + * two are offered. + */ +export const HOST_AVAILABLE_DISPLAY_MODES: readonly McpUiDisplayMode[] = [ + "inline", + "fullscreen", +]; + 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"; + /** + * Called when reading or posting the UI resource fails after the sandbox + * proxy is ready. Without this the user is left staring at a blank-but-live + * frame; the error is also always console.error'd. + */ + onResourceError?: (err: Error) => void; } /** First text content block of a UI resource, plus its `_meta` (sandbox hints). */ @@ -92,6 +93,86 @@ function extractHtmlAndMeta(result: ReadResourceResult): { throw new Error("UI resource has no text (HTML) content"); } +/** + * Decode a base64-encoded blob resource into bytes for download. Allocates the + * backing store explicitly so the return type is `Uint8Array` + * (Blob accepts `ArrayBufferView`, not the wider + * `ArrayBufferLike`). + */ +function base64ToBytes(b64: string): Uint8Array { + const binary = atob(b64); + const bytes = new Uint8Array(new ArrayBuffer(binary.length)); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes; +} + +/** + * Upper bound on items honored from a single `ui/download-file` request. One + * user approval must not fan out into an unbounded number of saves / new tabs. + */ +const MAX_DOWNLOAD_ITEMS = 20; + +/** + * Strip control characters and clamp length so a server-supplied filename or + * URI cannot forge additional lines in the confirmation prompt or push the + * real summary off-screen. + */ +function sanitizeDownloadLabel(label: string): string { + // Cc = control chars (newlines, escape, etc.); Cf = format chars (bidi + // overrides, zero-width joiners, BOM) — both can spoof or reflow the prompt. + const cleaned = label.replace(/[\p{Cc}\p{Cf}]+/gu, " ").trim(); + // Keep the START of an over-long label: for a link that preserves the + // scheme+host, which is what the user needs to make a trust decision. + return cleaned.length > 80 ? cleaned.slice(0, 77) + "..." : cleaned; +} + +/** + * Human-readable label for a download item, shown in the confirmation prompt. + * `forPrompt` marks a resource_link with a leading "↗" so the user can tell a + * link that will *open in a tab* apart from an embedded file that will *save to + * disk* — the two item kinds share this "download" confirmation. + */ +function describeDownloadItem( + item: EmbeddedResource | ResourceLink, + forPrompt = false, +): string { + if (item.type === "resource_link") { + return forPrompt ? `↗ ${item.uri}` : item.uri; + } + return fileNameFromUri(item.resource.uri); +} + +/** + * Trigger a browser download for a single MCP resource item. Inline + * {@link EmbeddedResource}s (text or base64 blob) are written via + * {@link downloadBlob}. A {@link ResourceLink} is *opened* in a new tab — + * the inspector does not fetch the URL to disk itself, since the link may + * require auth or content negotiation the browser can supply but we cannot. + * Returns false when the item carries nothing downloadable or its URI is + * rejected by the http(s)-only allowlist. + */ +function downloadResourceItem(item: EmbeddedResource | ResourceLink): boolean { + if (item.type === "resource_link") { + const parsed = isHttpUrl(item.uri); + if (!parsed) return false; + window.open(parsed.href, "_blank", "noopener,noreferrer"); + return true; + } + const resource = item.resource; + // The types forbid it, but the payload is untrusted: a resource with neither + // `blob` nor a string `text` has nothing to save. Skip it (like a rejected + // link) rather than writing a file containing the literal text "undefined". + if (!("blob" in resource) && typeof resource.text !== "string") return false; + const blob = + "blob" in resource + ? new Blob([base64ToBytes(resource.blob)], { + type: resource.mimeType ?? "application/octet-stream", + }) + : new Blob([resource.text], { type: resource.mimeType ?? "text/plain" }); + downloadBlob(fileNameFromUri(resource.uri), blob); + return true; +} + /** * Builds the {@link BridgeFactory} the AppRenderer uses to bring a sandbox * iframe to life. For each mounted iframe + tool it: @@ -99,9 +180,13 @@ function extractHtmlAndMeta(result: ReadResourceResult): { * 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, + * resource and pushes its HTML + sandbox/permissions/CSP into the inner + * iframe, echoing the applied sandbox config back via hostCapabilities, * 3. handles `openLinks` by opening http(s) URLs in a new tab, - * 4. connects a {@link PostMessageTransport} to the iframe and returns the + * 4. handles `downloadFile` by confirming with the user, then writing each + * embedded resource to disk via an object-URL anchor (resource links are + * opened in a new tab), + * 5. connects a {@link PostMessageTransport} to the iframe and returns the * live bridge. * * Host-initiated tool input/result are pushed separately through the renderer's @@ -123,15 +208,21 @@ export function createAppBridgeFactory( throw new Error("Cannot render MCP App: sandbox iframe has no window."); } - const bridge = new AppBridge(client, HOST_INFO, HOST_CAPABILITIES, { - hostContext: { theme: currentTheme() }, + // Per-app copy so the approved-sandbox echo (set on sandboxready below) + // never mutates the shared HOST_CAPABILITIES constant — each app may + // declare its own csp/permissions. + const hostCapabilities: McpUiHostCapabilities = { ...HOST_CAPABILITIES }; + const bridge = new AppBridge(client, HOST_INFO, hostCapabilities, { + hostContext: snapshotHostContext(iframe, HOST_AVAILABLE_DISPLAY_MODES), }); // 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. + // hints from the resource _meta) to the inner sandboxed iframe. A failure + // here is the case a developer most needs surfaced (their app's resource is + // erroring or malformed) — log it and report it via deps.onResourceError so + // the host can show something better than a blank frame. The bridge stays + // live so a retry path remains possible. bridge.addEventListener("sandboxready", () => { void (async () => { try { @@ -139,13 +230,42 @@ export function createAppBridgeFactory( if (!uri) return; const result = await deps.readResource(uri); const { html, meta } = extractHtmlAndMeta(result); + // Build the per-app CSP host-side: filter the requested sources to + // ones the host accepts, render the policy string, and wrap the + // app's HTML in a fixed shell whose first child is the CSP + // . The proxy assigns that document to srcdoc verbatim — it + // never parses the untrusted bytes — so the policy is guaranteed to + // apply before any app content loads. The approved (post-filter) csp + // is what we echo back via hostCapabilities.sandbox so the view sees + // what was granted, not what it asked for. Set before + // sendSandboxResourceReady: the view only sends ui/initialize once it + // has the HTML, so the bridge reflects this in the initialize result. + const approvedCsp = approveCspSources(meta?.csp); + // NOTE on the CSP-vs-permissions asymmetry: `csp` is injection-filtered + // by approveCspSources because its sources are interpolated into the + // CSP content string. `permissions` is NOT filtered here — it is + // a structured object (camera/microphone/geolocation/clipboardWrite + // booleans), and its only consumer is the sandbox proxy's + // buildAllowAttribute(), which maps each known key to a fixed + // Permissions-Policy token and ignores anything else. Untrusted values + // therefore can't reach the iframe `sandbox`/`allow` attribute as raw + // text (that layer, and the allow-same-origin strip, is owned by the + // sandbox-hardening work in #1565), so no source-style allowlist applies. + hostCapabilities.sandbox = { + permissions: meta?.permissions, + csp: approvedCsp, + }; await bridge.sendSandboxResourceReady({ - html, + html: wrapSandboxedHtml(html, buildSandboxCspPolicy(approvedCsp)), permissions: meta?.permissions, - csp: meta?.csp, }); - } catch { - /* read/post failed (or bridge closed) — leave the frame empty */ + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + console.error( + "[mcp-app] failed to load UI resource into sandbox:", + error, + ); + deps.onResourceError?.(error); } })(); }); @@ -158,6 +278,54 @@ export function createAppBridgeFactory( return { isError: true }; }; + // The view asks the host to save MCP resource contents to disk (sandboxed + // iframes can't download directly). Confirm with the user first — the spec + // requires a host-mediated confirmation — then write each item out. A + // declined prompt, an empty/oversized payload, or a thrown error all + // return isError. + bridge.ondownloadfile = async ({ contents }) => { + if (!Array.isArray(contents) || contents.length === 0) { + return { isError: true }; + } + // Sanity cap: one approval must not fan out into an unbounded number of + // downloads / new tabs. A buggy or hostile app requesting hundreds of + // items is rejected outright rather than acted on. + if (contents.length > MAX_DOWNLOAD_ITEMS) { + console.warn( + `[mcp-app] refusing download batch of ${contents.length} items (max ${MAX_DOWNLOAD_ITEMS})`, + ); + return { isError: true }; + } + const summary = contents + .map((item) => sanitizeDownloadLabel(describeDownloadItem(item, true))) + .join("\n"); + const approved = window.confirm( + `This MCP App wants to download or open ${contents.length} item(s):\n\n${summary}`, + ); + if (!approved) return { isError: true }; + let succeeded = 0; + const skipped: string[] = []; + for (const item of contents) { + try { + if (downloadResourceItem(item)) { + succeeded++; + } else { + skipped.push(describeDownloadItem(item)); + } + } catch (err) { + skipped.push(describeDownloadItem(item)); + console.error("[mcp-app] download item failed:", err); + } + } + if (skipped.length > 0) { + console.warn( + `[mcp-app] ${skipped.length} of ${contents.length} download item(s) skipped:`, + skipped, + ); + } + return { isError: succeeded === 0 }; + }; + const transport = new PostMessageTransport(targetWindow, targetWindow); await bridge.connect(transport); return bridge; diff --git a/clients/web/src/components/elements/AppRenderer/hostContext.ts b/clients/web/src/components/elements/AppRenderer/hostContext.ts index 9ea6f8f08..eca568827 100644 --- a/clients/web/src/components/elements/AppRenderer/hostContext.ts +++ b/clients/web/src/components/elements/AppRenderer/hostContext.ts @@ -148,6 +148,11 @@ export function snapshotHostContext( : undefined; return { theme: currentTheme(), + // Seed assumes the app opens inline. AppsScreen always mounts the renderer + // inline (maximize is a later user action), so this holds today; the live + // displayMode push (AppRenderer's displayMode effect, wired by #1568) + // carries any subsequent inline↔fullscreen transition. If a caller ever + // mounts already-maximized, thread the actual mode in here instead. displayMode: "inline", availableDisplayModes: [...availableDisplayModes], ...(styles ? { styles } : {}), diff --git a/clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.tsx b/clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.tsx index 40e56a6ce..09c25a1f4 100644 --- a/clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.tsx +++ b/clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.tsx @@ -71,6 +71,7 @@ export function AppDetailPanel({ onClick={onOpenApp} disabled={disabled} loading={isOpening} + data-testid="open-app" > Open App diff --git a/clients/web/src/components/screens/AppsScreen/AppsScreen.stories.tsx b/clients/web/src/components/screens/AppsScreen/AppsScreen.stories.tsx index a517e8feb..319dc7f2a 100644 --- a/clients/web/src/components/screens/AppsScreen/AppsScreen.stories.tsx +++ b/clients/web/src/components/screens/AppsScreen/AppsScreen.stories.tsx @@ -34,6 +34,7 @@ function createMockBridge(): AppBridge { sendToolInput: async () => {}, sendToolResult: async () => {}, sendToolCancelled: async () => {}, + sendHostContextChange: async () => {}, teardownResource: async () => ({}), close: async () => {}, addEventListener: () => {}, @@ -220,6 +221,7 @@ export const EchoRunning: Story = { setEcho(first?.type === "text" ? first.text : ""); }, sendToolCancelled: async () => {}, + sendHostContextChange: async () => {}, teardownResource: async () => ({}), close: async () => {}, }; diff --git a/clients/web/src/components/screens/AppsScreen/AppsScreen.test.tsx b/clients/web/src/components/screens/AppsScreen/AppsScreen.test.tsx index 9157918b4..7c9c0dcfa 100644 --- a/clients/web/src/components/screens/AppsScreen/AppsScreen.test.tsx +++ b/clients/web/src/components/screens/AppsScreen/AppsScreen.test.tsx @@ -1,7 +1,9 @@ import { createRef, useState } from "react"; +import { act } from "@testing-library/react"; import { describe, it, expect, vi } from "vitest"; import userEvent from "@testing-library/user-event"; -import type { Tool } from "@modelcontextprotocol/sdk/types.js"; +import type { McpUiDisplayMode } from "@modelcontextprotocol/ext-apps/app-bridge"; +import type { ContentBlock, Tool } from "@modelcontextprotocol/sdk/types.js"; import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge"; import { renderWithMantine, @@ -53,12 +55,92 @@ const cohortApp: Tool = { const okBridgeFactory: BridgeFactory = () => ({ sendToolInput: async () => {}, + sendToolInputPartial: async () => {}, sendToolResult: async () => {}, sendToolCancelled: async () => {}, + sendHostContextChange: async () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, teardownResource: async () => ({}), close: async () => {}, }) as unknown as AppBridge; +// A bridge factory whose bridge supports the addEventListener/emit surface the +// AppRenderer wires up, so a test can drive a bridge event (sizechange) through +// to the screen's handling. `emit` dispatches to the captured listeners; +// `bridges` exposes each built bridge so tests can also drive the per-bridge +// handlers (onrequestdisplaymode). +function createEventBridgeFactory(): { + factory: BridgeFactory; + bridges: AppBridge[]; + emit: (event: string, payload?: unknown) => void; +} { + const listeners: Record void)[]> = {}; + const bridges: AppBridge[] = []; + const factory: BridgeFactory = () => { + const bridge = { + sendToolInput: async () => {}, + sendToolInputPartial: async () => {}, + sendToolResult: async () => {}, + sendToolCancelled: async () => {}, + sendHostContextChange: async () => {}, + teardownResource: async () => ({}), + close: async () => {}, + addEventListener: (event: string, handler: (p: unknown) => void) => { + (listeners[event] ??= []).push(handler); + }, + removeEventListener: () => {}, + } as unknown as AppBridge; + bridges.push(bridge); + return bridge; + }; + return { + factory, + bridges, + emit: (event, payload) => + (listeners[event] ?? []).forEach((h) => h(payload)), + }; +} + +// Invoke the onrequestdisplaymode handler the screen attached to the latest +// bridge, mimicking a ui/request-display-mode request from the running view. +async function requestDisplayMode( + bridges: AppBridge[], + mode: McpUiDisplayMode, +): Promise<{ mode: McpUiDisplayMode }> { + const handler = bridges.at(-1)?.onrequestdisplaymode as unknown as + | ((params: { mode: McpUiDisplayMode }) => Promise<{ + mode: McpUiDisplayMode; + }>) + | undefined; + if (!handler) throw new Error("no onrequestdisplaymode handler attached"); + let result: { mode: McpUiDisplayMode } = { mode }; + await act(async () => { + result = await handler({ mode }); + }); + return result; +} + +// Invoke the onmessage handler the screen attached to the latest bridge, +// mimicking a ui/message request from the running view. +async function sendUiMessage( + bridges: AppBridge[], + content: ContentBlock[], +): Promise> { + const onmessage = bridges.at(-1)?.onmessage as unknown as + | ((params: { + role: "user"; + content: ContentBlock[]; + }) => Promise>) + | undefined; + if (!onmessage) throw new Error("no onmessage handler attached"); + let result: Record = {}; + await act(async () => { + result = await onmessage({ role: "user", content }); + }); + return result; +} + function buildProps(overrides: Partial = {}): AppsScreenProps { return { tools: [fieldedApp, noFieldsApp, cohortApp] as Tool[], @@ -249,6 +331,231 @@ describe("AppsScreen", () => { expect(screen.getByText("MCP Apps (3)")).toBeInTheDocument(); }); + it("sizes the renderer frame to the view-reported height", async () => { + const user = userEvent.setup(); + const { factory, emit } = createEventBridgeFactory(); + renderWithMantine(); + // Auto-launches the no-fields app, mounting the renderer and registering + // its sizechange listener once the bridge resolves. + await user.click(screen.getByText("Ops Dashboard")); + const iframe = screen.getByTitle("Ops Dashboard"); + const frame = iframe.parentElement as HTMLElement; + // Until the view reports a size, the frame flex-grows to fill the card. + expect(frame.style.flexGrow).toBe("1"); + + await act(async () => { + // Let the synchronous bridge factory's promise chain settle so the + // sizechange listener is registered before we emit. + await Promise.resolve(); + await Promise.resolve(); + emit("sizechange", { height: 600 }); + }); + // A reported height switches the frame to its content size: it stops + // flex-growing and takes the explicit `h`. + expect(frame.style.flexGrow).toBe("0"); + }); + + it("ignores a size-changed report that carries no height", async () => { + const user = userEvent.setup(); + const { factory, emit } = createEventBridgeFactory(); + renderWithMantine(); + await user.click(screen.getByText("Ops Dashboard")); + const frame = screen.getByTitle("Ops Dashboard") + .parentElement as HTMLElement; + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + emit("sizechange", { width: 400 }); + }); + // No height in the report — the frame keeps flex-growing. + expect(frame.style.flexGrow).toBe("1"); + }); + + it("ignores a size-changed report with a non-positive height", async () => { + const user = userEvent.setup(); + const { factory, emit } = createEventBridgeFactory(); + renderWithMantine(); + await user.click(screen.getByText("Ops Dashboard")); + const frame = screen.getByTitle("Ops Dashboard") + .parentElement as HTMLElement; + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + // A transient 0 (pre-layout / teardown) must not collapse the frame. + emit("sizechange", { height: 0 }); + }); + expect(frame.style.flexGrow).toBe("1"); + // A subsequent positive report is honored. + await act(async () => emit("sizechange", { height: 480 })); + expect(frame.style.flexGrow).toBe("0"); + }); + + it("maximizes the frame when the view requests fullscreen via request-display-mode", async () => { + const user = userEvent.setup(); + const { factory, bridges } = createEventBridgeFactory(); + renderWithMantine(); + await user.click(screen.getByText("Ops Dashboard")); + expect(screen.getByText("MCP Apps (3)")).toBeInTheDocument(); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + const result = await requestDisplayMode(bridges, "fullscreen"); + expect(result).toEqual({ mode: "fullscreen" }); + // Fullscreen hides the sidebar (same effect as the Maximize button). + expect(screen.queryByText("MCP Apps (3)")).not.toBeInTheDocument(); + }); + + it("declines an unsupported display mode, returning the current mode", async () => { + const user = userEvent.setup(); + const { factory, bridges } = createEventBridgeFactory(); + renderWithMantine(); + await user.click(screen.getByText("Ops Dashboard")); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + // "pip" is not in HOST_AVAILABLE_DISPLAY_MODES — declined, current mode + // ("inline") returned, and the sidebar stays visible. + const result = await requestDisplayMode(bridges, "pip"); + expect(result).toEqual({ mode: "inline" }); + expect(screen.getByText("MCP Apps (3)")).toBeInTheDocument(); + }); + + it("restores inline via request-display-mode after maximizing", async () => { + const user = userEvent.setup(); + const { factory, bridges } = createEventBridgeFactory(); + renderWithMantine(); + await user.click(screen.getByText("Ops Dashboard")); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + await requestDisplayMode(bridges, "fullscreen"); + expect(screen.queryByText("MCP Apps (3)")).not.toBeInTheDocument(); + const result = await requestDisplayMode(bridges, "inline"); + expect(result).toEqual({ mode: "inline" }); + expect(screen.getByText("MCP Apps (3)")).toBeInTheDocument(); + }); + + it("surfaces ui/message content from the view in the message log", async () => { + const user = userEvent.setup(); + const { factory, bridges } = createEventBridgeFactory(); + renderWithMantine(); + await user.click(screen.getByText("Ops Dashboard")); + await vi.waitFor(() => + expect(bridges.at(-1)?.onmessage).toBeTypeOf("function"), + ); + await sendUiMessage(bridges, [ + { type: "text", text: "hello from the app" }, + ]); + expect(screen.getByText(/Messages from app \(1\)/)).toBeInTheDocument(); + expect(screen.getByText(/hello from the app/)).toBeInTheDocument(); + expect(screen.getByTestId("apps-messages")).toBeInTheDocument(); + }); + + it("returns an empty ui/message result (no conversation content leak)", async () => { + const user = userEvent.setup(); + const { factory, bridges } = createEventBridgeFactory(); + renderWithMantine(); + await user.click(screen.getByText("Ops Dashboard")); + await vi.waitFor(() => + expect(bridges.at(-1)?.onmessage).toBeTypeOf("function"), + ); + const result = await sendUiMessage(bridges, [ + { type: "text", text: "secret" }, + ]); + expect(result).toEqual({}); + }); + + it("clears the message log when the app is closed", async () => { + const user = userEvent.setup(); + const { factory, bridges } = createEventBridgeFactory(); + renderWithMantine(); + await user.click(screen.getByText("Ops Dashboard")); + await vi.waitFor(() => + expect(bridges.at(-1)?.onmessage).toBeTypeOf("function"), + ); + await sendUiMessage(bridges, [ + { type: "text", text: "hello from the app" }, + ]); + expect(screen.getByText(/Messages from app/)).toBeInTheDocument(); + await user.click(screen.getByLabelText("Close")); + expect(screen.queryByText(/Messages from app/)).not.toBeInTheDocument(); + }); + + it("surfaces app log notifications in a default-expanded collapsible panel with a working Clear", async () => { + const user = userEvent.setup(); + const { factory, emit } = createEventBridgeFactory(); + renderWithMantine(); + await user.click(screen.getByText("Ops Dashboard")); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(screen.queryByText(/App logs/)).not.toBeInTheDocument(); + await act(async () => { + emit("loggingmessage", { level: "warning", data: "disk almost full" }); + emit("loggingmessage", { + level: "error", + logger: "render", + data: { code: 500 }, + }); + }); + const toggle = screen.getByRole("button", { name: /App logs \(2\)/ }); + expect(toggle).toBeInTheDocument(); + expect(toggle.getAttribute("aria-expanded")).toBe("true"); + // Default-expanded: entries are visible without clicking the toggle. + expect(screen.getByText("disk almost full")).toBeInTheDocument(); + expect(screen.getByText("render")).toBeInTheDocument(); + expect(screen.getByText('{"code":500}')).toBeInTheDocument(); + expect(screen.getByTestId("apps-logs")).toBeInTheDocument(); + // Collapsing still works, and Clear empties the panel. + await user.click(toggle); + expect(toggle.getAttribute("aria-expanded")).toBe("false"); + // The toggle points at the collapse region for assistive tech. + expect(toggle.getAttribute("aria-controls")).toBe("apps-logs-region"); + await user.click(screen.getByRole("button", { name: "Clear" })); + expect(screen.queryByText(/App logs/)).not.toBeInTheDocument(); + }); + + it("renders a log notification that carries no data without crashing", async () => { + const user = userEvent.setup(); + const { factory, emit } = createEventBridgeFactory(); + renderWithMantine(); + await user.click(screen.getByText("Ops Dashboard")); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + // No `data` field — formatLogData must coalesce to "" (not "undefined"). + emit("loggingmessage", { level: "info" }); + }); + expect( + screen.getByRole("button", { name: /App logs \(1\)/ }), + ).toBeInTheDocument(); + }); + + it("caps retained app logs at the soft limit, dropping the oldest", async () => { + const user = userEvent.setup(); + const { factory, emit } = createEventBridgeFactory(); + renderWithMantine(); + await user.click(screen.getByText("Ops Dashboard")); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + // 501 entries exceeds the 500 cap → oldest dropped, count clamps at 500. + for (let i = 0; i < 501; i++) { + emit("loggingmessage", { level: "info", data: `log-${i}` }); + } + }); + expect( + screen.getByRole("button", { name: /App logs \(500\)/ }), + ).toBeInTheDocument(); + // The very first entry was dropped; the most recent is retained. + expect(screen.queryByText("log-0")).not.toBeInTheDocument(); + expect(screen.getByText("log-500")).toBeInTheDocument(); + }); + it("calls onCloseApp and clears selection on Close", async () => { const user = userEvent.setup(); const onCloseApp = vi.fn(); @@ -353,4 +660,153 @@ describe("AppsScreen", () => { ).not.toBeInTheDocument(); expect(screen.getByText("Select an app to view details")).toBeVisible(); }); + + describe("data-app-status / data-app-error", () => { + function getStatus(): string | null { + return screen.getByTestId("apps-form").getAttribute("data-app-status"); + } + + it("is idle when no app is running", () => { + renderWithMantine( + , + ); + expect(getStatus()).toBe("idle"); + }); + + it("transitions idle → loading → ready around the view's initialized signal", async () => { + const user = userEvent.setup(); + const { factory, emit } = createEventBridgeFactory(); + renderWithMantine(); + expect(getStatus()).toBe("idle"); + await user.click(screen.getByText("Ops Dashboard")); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(getStatus()).toBe("loading"); + await act(async () => emit("initialized")); + expect(getStatus()).toBe("ready"); + }); + + it("reports error status + surfaces the reason in an error panel and data-app-error when the bridge factory rejects", async () => { + const user = userEvent.setup(); + const onError = vi.fn(); + const failingFactory: BridgeFactory = () => + Promise.reject(new Error("connect refused")); + renderWithMantine( + , + ); + await user.click(screen.getByText("Ops Dashboard")); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(getStatus()).toBe("error"); + const form = screen.getByTestId("apps-form"); + expect(form.getAttribute("data-app-error")).toBe("connect refused"); + // The error panel is shown below the frame with the reason, so the + // failure isn't a silent blank frame. + expect(screen.getByTestId("apps-error")).toBeInTheDocument(); + expect(screen.getByText("App failed to load")).toBeInTheDocument(); + expect(screen.getByText("connect refused")).toBeInTheDocument(); + // The error is also forwarded to the parent onError. + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ message: "connect refused" }), + ); + }); + + it("resets to idle and clears the error panel when the running app is closed", async () => { + const user = userEvent.setup(); + const failingFactory: BridgeFactory = () => + Promise.reject(new Error("connect refused")); + renderWithMantine( + , + ); + await user.click(screen.getByText("Ops Dashboard")); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(screen.getByTestId("apps-error")).toBeInTheDocument(); + await user.click(screen.getByLabelText("Close")); + expect(screen.queryByTestId("apps-error")).not.toBeInTheDocument(); + }); + }); + + it("stages partial-input snapshots from the form and clears them", async () => { + const user = userEvent.setup(); + renderWithMantine(); + // No-fields apps don't show the control. + await user.click(screen.getByText("Ops Dashboard")); + expect( + screen.queryByRole("button", { name: "Stage partial input" }), + ).not.toBeInTheDocument(); + // Fielded apps do. + await user.click(screen.getByText("Weather Widget")); + const stage = screen.getByRole("button", { name: "Stage partial input" }); + expect(stage).toBeInTheDocument(); + expect(screen.queryByText(/staged/)).not.toBeInTheDocument(); + await user.click(stage); + await user.click(stage); + expect(screen.getByText("2 staged")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Clear staged" })); + expect(screen.queryByText(/staged/)).not.toBeInTheDocument(); + }); + + it("replays staged partial inputs before the final tool-input on Open App", async () => { + const user = userEvent.setup(); + const partials: Record[] = []; + let onInitialized: (() => void) | undefined; + let sawInput = false; + const factory: BridgeFactory = () => { + const listeners: Record void)[]> = {}; + const bridge = { + sendToolInput: async () => { + sawInput = true; + }, + sendToolInputPartial: async (params: { + arguments?: Record; + }) => { + // Assert partials arrive before the final input. + expect(sawInput).toBe(false); + partials.push(params.arguments ?? {}); + }, + sendToolResult: async () => {}, + sendToolCancelled: async () => {}, + sendHostContextChange: async () => {}, + teardownResource: async () => ({}), + close: async () => {}, + addEventListener: (event: string, handler: () => void) => { + (listeners[event] ??= []).push(handler); + if (event === "initialized") onInitialized = handler; + }, + removeEventListener: () => {}, + } as unknown as AppBridge; + return bridge; + }; + renderWithMantine(); + await user.click(screen.getByText("Weather Widget")); + // Fill the required field so Open App is enabled, then stage two snapshots. + await user.type(screen.getByRole("textbox", { name: /city/i }), "NYC"); + const stage = screen.getByRole("button", { name: "Stage partial input" }); + await user.click(stage); + await user.click(stage); + await user.click(screen.getByRole("button", { name: /Open App/ })); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + onInitialized?.(); + }); + // Both staged snapshots were replayed (staged partials survive Open). No + // final tool-input arrives because the parent's onOpenApp is a no-op here + // (App drives the renderer handle), so sawInput stays false — the partials + // are what this screen is responsible for. + expect(partials).toHaveLength(2); + expect(sawInput).toBe(false); + }); }); diff --git a/clients/web/src/components/screens/AppsScreen/AppsScreen.tsx b/clients/web/src/components/screens/AppsScreen/AppsScreen.tsx index 127f43c67..dd85b02ae 100644 --- a/clients/web/src/components/screens/AppsScreen/AppsScreen.tsx +++ b/clients/web/src/components/screens/AppsScreen/AppsScreen.tsx @@ -1,13 +1,18 @@ -import { useState, type Ref } from "react"; +import { useRef, useState, type Ref } from "react"; import { ActionIcon, Button, Card, + Code, + Collapse, Flex, Group, Image, + Paper, + ScrollArea, Stack, Text, + Title, Tooltip, } from "@mantine/core"; import { @@ -16,14 +21,26 @@ import { MdFullscreen, MdFullscreenExit, } from "react-icons/md"; -import type { Tool } from "@modelcontextprotocol/sdk/types.js"; +import type { + ContentBlock, + LoggingMessageNotification, + Tool, +} from "@modelcontextprotocol/sdk/types.js"; +import type { + AppBridgeEventMap, + McpUiDisplayMode, +} from "@modelcontextprotocol/ext-apps/app-bridge"; import { AppRenderer, type AppRendererHandle, + type AppRendererStatus, type BridgeFactory, } from "../../elements/AppRenderer/AppRenderer"; +import { HOST_AVAILABLE_DISPLAY_MODES } from "../../elements/AppRenderer/createAppBridgeFactory"; import { AppDetailPanel } from "../../groups/AppDetailPanel/AppDetailPanel"; import { AppControls } from "../../groups/AppControls/AppControls"; +import { ContentViewer } from "../../elements/ContentViewer/ContentViewer"; +import { LogLevelBadge } from "../../elements/LogLevelBadge/LogLevelBadge"; import { hasInputFields, resolveDisplayLabel } from "../../../utils/toolUtils"; import { collectSchemaDefaults } from "../../../utils/jsonUtils"; @@ -127,18 +144,182 @@ const HeaderActions = Group.withProps({ wrap: "nowrap", }); -const RendererFrame = Stack.withProps({ +// The host-controlled box the running app sits within. Its size is driven by +// the host's layout (window resize, sidebar toggle, maximize) and NOT by the +// view's reported content height — that drives the inner RendererFrame — so the +// renderer's containerDimensions observer can measure this element without +// coupling host→view container size to view→host size-changed. +const RendererContainer = Stack.withProps({ flex: 1, miw: 0, mih: 0, gap: 0, }); +// The inner box that actually holds the iframe. Sized by the view-reported +// content height (see `contentHeight`) and capped at the outer container. +// Distinct from RendererContainer above so the two roles read clearly in JSX. +const RendererFrame = Stack.withProps({ + miw: 0, + mih: 0, + gap: 0, +}); + const ContentStack = Stack.withProps({ gap: "md", h: "100%", }); +// Pinned panel below the running app (used by both the message log and the +// app-log panel). `0 0 auto` keeps it at its content height (capped by the +// inner scroll's `mah`) so it never squeezes out the iframe above it. +const PinnedPanel = Stack.withProps({ + gap: "xs", + flex: "0 0 auto", + mih: 0, +}); + +const LogScroll = ScrollArea.withProps({ + mah: 200, + type: "auto", + scrollbars: "y", + offsetScrollbars: true, +}); + +const MessageLogStack = Stack.withProps({ + gap: "sm", +}); + +const MessageItem = Paper.withProps({ + p: "md", + radius: "md", + withBorder: true, +}); + +const MessageItemStack = Stack.withProps({ + gap: "xs", +}); + +const MonoCaption = Text.withProps({ + size: "xs", + c: "dimmed", + ff: "monospace", +}); + +const AppLogList = Stack.withProps({ + gap: "xs", +}); + +const AppLogRow = Group.withProps({ + gap: "sm", + wrap: "nowrap", + align: "flex-start", +}); + +const AppLogData = Code.withProps({ + block: true, + fz: "xs", +}); + +const CompactSubtleButton = Button.withProps({ + variant: "subtle", + size: "compact-xs", +}); + +const PanelHeaderRow = Group.withProps({ + justify: "space-between", + wrap: "nowrap", + gap: "sm", +}); + +const PartialStageControls = Group.withProps({ + gap: "sm", + wrap: "nowrap", + align: "center", + flex: "0 0 auto", +}); + +const StagePartialButton = Button.withProps({ + variant: "default", + size: "compact-xs", +}); + +const PartialStageCount = Text.withProps({ + size: "xs", + c: "dimmed", +}); + +const AppErrorPanel = Paper.withProps({ + p: "md", + radius: "md", + withBorder: true, + c: "var(--inspector-log-error)", +}); + +const AppErrorTitle = Text.withProps({ + fw: 600, + size: "sm", +}); + +const AppErrorMessage = Text.withProps({ + size: "sm", + ff: "monospace", +}); + +/** Render a log payload as a string for display. */ +function formatLogData(data: unknown): string { + if (typeof data === "string") return data; + try { + // JSON.stringify(undefined) returns the value `undefined`, not a string, so + // coalesce to "" to keep the `: string` return type honest for a data-less + // log (spec-required, so this is only defensive against a malformed view). + return JSON.stringify(data) ?? ""; + } catch { + /* v8 ignore next -- JSON.stringify only throws on a BigInt or a circular + structure; a log payload delivered over postMessage is already + structured-clone-safe, so this fallback is unreachable in practice. */ + return String(data); + } +} + +/** + * Soft cap on retained message / log entries per run. Chatty widgets can emit + * logs in a loop; keep only the most recent so the panels (and their DOM rows) + * don't grow without bound between Clear/close. Oldest entries are dropped. + */ +const MAX_APP_CHANNEL_ENTRIES = 500; + +/** Append to a capped list, dropping the oldest entries past the cap. */ +function appendCapped(prev: T[], next: T): T[] { + const grown = [...prev, next]; + return grown.length > MAX_APP_CHANNEL_ENTRIES + ? grown.slice(grown.length - MAX_APP_CHANNEL_ENTRIES) + : grown; +} + +// A user-role message submitted by the running view through ui/message. The +// inspector has no conversation to append to, so it just records the content +// blocks for display. `role`/`content` mirror McpUiMessageRequest["params"]; +// `id` is a stable React key (like AppLogEntry) so the appendCapped front-drop +// can't renumber keys the way an array index would. +interface AppMessage { + id: number; + role: "user"; + content: ContentBlock[]; +} + +/** + * One MCP `notifications/message` log entry from the running app, with the + * payload stringified once at capture time so the render path can use it + * directly. `id` is a stable React key. + */ +interface AppLogEntry { + id: number; + level: LoggingMessageNotification["params"]["level"]; + logger?: string; + text: string; +} + export function AppsScreen({ tools, listChanged, @@ -156,12 +337,125 @@ export function AppsScreen({ const { selectedAppName, formValues, search } = ui; const [running, setRunning] = useState(false); const [maximized, setMaximized] = useState(false); + const rendererContainerRef = useRef(null); + const nextLogIdRef = useRef(0); + const nextMessageIdRef = useRef(0); + // Height (px) the running view last reported via ui/notifications/size-changed. + // Undefined until the view reports (or after it's torn down), in which case + // the iframe fills the available card space as before. Local to the screen + // like `running`/`maximized`: it's tied to the live iframe, not persisted. + const [appHeight, setAppHeight] = useState(undefined); + // Messages the running view has pushed via ui/message. The inspector has no + // chat loop, so they're collected here and shown in a log below the app + // rather than continuing a conversation. Local to the screen like `running`: + // tied to the live bridge, cleared when the open ends or the app changes. + const [messages, setMessages] = useState([]); + // Standard MCP log notifications (notifications/message) the running app + // emits. The host advertises the `logging` capability; without surfacing + // these here they'd be silently dropped by the bridge. Same lifecycle as + // `messages`: tied to the live bridge, cleared on open/close/switch. + const [appLogs, setAppLogs] = useState([]); + // Expanded by default so a widget developer sees the entries without an extra + // click. The user can still collapse it for the rest of the run. + const [appLogsExpanded, setAppLogsExpanded] = useState(true); + // Snapshots of the input form captured via "Stage partial input". On Open + // App they're passed to AppRenderer as `partialInputs` and replayed via + // ui/notifications/tool-input-partial before the complete tool-input, so a + // widget's progressive-render path can be exercised. Cleared on switch/close. + const [partialStages, setPartialStages] = useState[]>( + [], + ); + // High-level renderer lifecycle, surfaced as `data-app-status` on the + // `apps-form` card so an automated driver can poll + // `[data-app-status="ready"]` instead of racing the iframe selector. + const [appStatus, setAppStatus] = useState( + "idle", + ); + // The error that put the renderer into status="error" (factory throw, no + // connected client). Shown in place of the blank iframe and surfaced as + // `data-app-error` on the `apps-form` card so an automated driver can read + // *why* the open failed without screenshotting a toast. + const [appError, setAppError] = useState(undefined); const selectedTool = selectedAppName ? tools.find((t) => t.name === selectedAppName) : undefined; const selectedHasFields = selectedTool ? hasInputFields(selectedTool) : false; + // The running view reports its rendered content height via + // ui/notifications/size-changed; honor it so the iframe is neither clipped + // nor surrounded by dead space. Width is left at the host-controlled + // container width. The value is clamped to the available space by the + // renderer frame's `mah` below, and ignored while maximized (the app fills + // the screen instead). A non-positive height is ignored — a view's + // ResizeObserver can transiently fire 0 before layout settles or during + // teardown, which would otherwise collapse the frame (mirrors AppRenderer's + // own 0×0 skip on the container side). + function handleSizeChange(size: AppBridgeEventMap["sizechange"]) { + if (size.height != null && size.height > 0) setAppHeight(size.height); + } + + function handleMessage(params: Omit) { + setMessages((prev) => + appendCapped(prev, { id: nextMessageIdRef.current++, ...params }), + ); + } + + function handleLog(params: LoggingMessageNotification["params"]) { + setAppLogs((prev) => + appendCapped(prev, { + id: nextLogIdRef.current++, + level: params.level, + logger: params.logger, + text: formatLogData(params.data), + }), + ); + } + + // Clear the message + log panels (and the reported height). Called when a run + // ends or the selected app changes so a new run starts clean. `keepPartials` + // is set for `handleOpen`, where the staged fragments are about to be consumed + // by the renderer and must not be cleared first. + function resetAppChannels(opts?: { keepPartials?: boolean }) { + setAppHeight(undefined); + setMessages([]); + setAppLogs([]); + setAppLogsExpanded(true); + setAppStatus("idle"); + setAppError(undefined); + if (!opts?.keepPartials) setPartialStages([]); + } + + // Capture the error locally (so it can be shown in the card and surfaced as + // `data-app-error`) and forward it to the parent's onError. The renderer + // already drives `data-app-status="error"` via onAppStatusChange; this adds + // the *reason* alongside it. + function handleAppError(err: Error) { + setAppError(err); + onError?.(err); + } + + function handleStagePartialInput() { + setPartialStages((prev) => [...prev, { ...formValues }]); + } + + // The app's display mode is derived from the existing maximized toggle. + // Passed to AppRenderer so the running view receives it via + // host-context-changed; the Maximize/Restore button below keeps toggling + // `maximized`, which now flows out as a protocol event. + const displayMode: McpUiDisplayMode = maximized ? "fullscreen" : "inline"; + + // Handle a view-originated ui/request-display-mode. Only modes the inspector + // advertises in `availableDisplayModes` are honored — an unsupported request + // (e.g. "pip") is declined by returning the current mode, per spec. + function handleRequestDisplayMode( + requested: McpUiDisplayMode, + ): McpUiDisplayMode { + if (!HOST_AVAILABLE_DISPLAY_MODES.includes(requested)) return displayMode; + setMaximized(requested === "fullscreen"); + return requested; + } + function handleSelect(name: string) { if (name === selectedAppName) return; const next = tools.find((t) => t.name === name); @@ -174,6 +468,7 @@ export function AppsScreen({ formValues: collectSchemaDefaults(next.inputSchema), }); setMaximized(false); + resetAppChannels(); onSelectApp(name); // No-input apps auto-launch on selection so the user lands directly in // the running view; apps with fields wait for the explicit Open App click. @@ -187,6 +482,12 @@ export function AppsScreen({ function handleOpen() { if (!selectedTool) return; + // `keepPartials` preserves the staged fragments: AppRenderer snapshots them + // into its own pendingPartialsRef at bridge-build time and replays them. + // The `partialStages` state is intentionally NOT cleared here — the staging + // UI only renders while not running, so the surviving state is invisible + // until the next select/close/back reset drains it. + resetAppChannels({ keepPartials: true }); setRunning(true); onOpenApp(selectedTool.name, formValues); } @@ -195,12 +496,14 @@ export function AppsScreen({ setRunning(false); onUiChange({ ...ui, selectedAppName: undefined, formValues: {} }); setMaximized(false); + resetAppChannels(); onCloseApp(); } function handleBackToInput() { setRunning(false); setMaximized(false); + resetAppChannels(); } // No sandbox proxy URL means the host can't embed the trusted outer iframe @@ -218,6 +521,15 @@ export function AppsScreen({ ); } + // While maximized the app fills the screen, so the view-reported height is + // ignored; otherwise we honor it (clamped to the card by the frame's `mah`). + // `appHeight` is intentionally NOT cleared when toggling maximize: carrying + // the last inline height across a maximize→restore means the frame restores + // at its prior size immediately, rather than flashing to full-card height + // (flex:1) for the frame or two until the view sends a fresh size-changed + // after the `inline` host-context-changed. + const contentHeight = maximized ? undefined : appHeight; + return ( {!maximized && ( @@ -236,7 +548,13 @@ export function AppsScreen({ )} - + {selectedTool ? ( @@ -286,19 +604,46 @@ export function AppsScreen({ {running ? ( - - {/* Keying by name forces the renderer to remount when the - selected app changes, ensuring a fresh bridge and iframe - rather than reusing the previous app's transport. */} - - + // RendererContainer is the host-controlled box (its size only + // changes with host layout); the inner RendererFrame is sized by + // the view's reported content height, capped at the container. + + + {/* Keying by name forces the renderer to remount when the + selected app changes, ensuring a fresh bridge and iframe + rather than reusing the previous app's transport. */} + + + {/* Shown BELOW the frame on a factory throw/reject so the reason + is visible alongside the (blank) iframe rather than leaving a + silent blank frame. The renderer stays mounted so an in-place + retry path remains possible. */} + {appError && ( + + App failed to load + {appError.message} + + )} + ) : ( // `isOpening` is always false here because `handleOpen` // synchronously flips `running` to true, swapping in the @@ -307,15 +652,95 @@ export function AppsScreen({ // standalone use (the `Opening` story) and for Phase 3 // wiring, where a managed-state hook can hold the panel // in a pending state across an awaited `tools/call`. - - onUiChange({ ...ui, formValues: values }) - } - onOpenApp={handleOpen} - /> + <> + {selectedHasFields && ( + + + Stage partial input + + {partialStages.length > 0 && ( + <> + + {partialStages.length} staged + + setPartialStages([])} + > + Clear staged + + + )} + + )} + + onUiChange({ ...ui, formValues: values }) + } + onOpenApp={handleOpen} + /> + + )} + {running && messages.length > 0 && ( + + Messages from app ({messages.length}) + + + {messages.map((message, index) => ( + + + + [{index}] role: {message.role} + + {message.content.map((block, blockIndex) => ( + + ))} + + + ))} + + + + )} + {running && appLogs.length > 0 && ( + + + setAppLogsExpanded((e) => !e)} + aria-expanded={appLogsExpanded} + aria-controls="apps-logs-region" + > + App logs ({appLogs.length}) + + setAppLogs([])}> + Clear + + + + + + {appLogs.map((entry) => ( + + + {entry.logger && ( + {entry.logger} + )} + {entry.text} + + ))} + + + + )} ) : ( diff --git a/clients/web/src/lib/sandbox-csp.test.ts b/clients/web/src/lib/sandbox-csp.test.ts index 3cd471c2c..de7d63522 100644 --- a/clients/web/src/lib/sandbox-csp.test.ts +++ b/clients/web/src/lib/sandbox-csp.test.ts @@ -158,7 +158,7 @@ describe("wrapSandboxedHtml", () => { }); it("does not introduce a host-authored