From 0ddb79203a65fa690b355bb98a35aecd1e7e173a Mon Sep 17 00:00:00 2001 From: Cliff Hall Date: Sat, 11 Jul 2026 22:14:20 -0400 Subject: [PATCH] =?UTF-8?q?web:=20Wave=202=20=E2=80=94=20spec-conformant?= =?UTF-8?q?=20MCP=20Apps=20host=20(rollup=20of=20#1565=E2=80=93#1572)=20(#?= =?UTF-8?q?1660)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * web: sandbox hardening — opaque-origin inner frame, srcdoc-only, frame-ancestors Harden the MCP Apps sandbox isolation model: - The inner (untrusted-widget) iframe is sandboxed WITHOUT `allow-same-origin` (default "allow-scripts allow-forms") and any server-supplied sandbox value has `allow-same-origin` stripped case-insensitively, so the widget runs under an opaque origin and cannot reach the proxy's DOM to bypass its own CSP via the parent realm. - Widget delivery is `srcdoc`-only (no document.write), which respects the opaque origin. - The relay accepts the inner frame's "null" origin with `event.source === inner.contentWindow` as the unforgeable identity check. - sandbox-controller.ts serves the proxy with `Content-Security-Policy: frame-ancestors http://127.0.0.1:* http://localhost:*`; fetch directives are deliberately omitted so a srcdoc iframe's inherited policy container does not override the per-app CSP. Updated the isolation-model comments and the sandbox-controller integration test asserts the frame-ancestors header (and the absence of fetch directives). Closes #1565 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw * web: enforce per-app _meta.ui.csp in the app bridge + surface resource-read failures - createAppBridgeFactory now builds each app's `_meta.ui.csp` host-side via the sandbox-csp library (approveCspSources → buildSandboxCspPolicy) and wraps the untrusted widget HTML with wrapSandboxedHtml so the CSP is always the first child before the HTML reaches the sandboxed iframe. The approved (post-filter) csp + permissions are echoed back via hostCapabilities.sandbox so the view sees what was granted. The csp is no longer sent inline through sendSandboxResourceReady — it is enforced via the wrapped meta. - A per-app copy of HOST_CAPABILITIES prevents the sandbox echo from mutating the shared constant. - New optional `onResourceError` dep on AppBridgeFactoryDeps routes resource read/post failures to the caller (also always console.error'd) instead of leaving a silent blank frame. Closes #1566 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw * web: full hostContext delivery + live host-context-changed updates - createAppBridgeFactory now seeds the full hostContext via snapshotHostContext(iframe, HOST_AVAILABLE_DISPLAY_MODES): theme, styles (Mantine design tokens), displayMode ("inline"), availableDisplayModes, and containerDimensions — replacing the theme-only seed. The local currentTheme helper is dropped in favor of hostContext.ts. - AppRenderer gains `displayMode` and `containerRef` props and pushes live host-context changes via the SDK's sendHostContextChange (partial host-context-changed notifications), never a hand-maintained snapshot: - theme + styles via a MutationObserver on , - containerDimensions via a ResizeObserver on the host container (or the iframe fallback), gated on `initialized`, skipping 0x0 and value-equal repeats, plus a one-shot push on initialize once layout settles, - displayMode whenever the prop changes (Maximize/Restore), gated on init. Adds AppRenderer host-context tests (theme flip incl. resolved styles, displayMode push + init gate, container-dimensions on init and resize, observer teardown) and updates the factory + story mocks for the new hostContext shape and sendHostContextChange method. Closes #1567 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw * web: honor ui/notifications/size-changed and ui/request-display-mode AppRenderer: - New `onSizeChange` prop forwards the view's ui/notifications/size-changed reports (via the bridge `sizechange` event). - New `onRequestDisplayMode` prop backs an `onrequestdisplaymode` bridge handler that returns the mode the host actually applied; with no handler the request is declined by returning the current host-side mode (falling back to inline). AppsScreen: - Adds a host-controlled RendererContainer (measured for containerDimensions) with an inner box sized by the view-reported height (`appHeight`), so host→view container size and view→host size-changed don't couple. - `handleSizeChange` records the reported height (ignored while maximized); `displayMode` is derived from the maximize toggle and pushed to the view; `handleRequestDisplayMode` filters against HOST_AVAILABLE_DISPLAY_MODES (declining e.g. "pip") and maps fullscreen↔inline onto the maximize state. Adds AppRenderer tests (size forwarding, request-display-mode apply/decline/ fallback) and AppsScreen tests (frame sizing on report, no-height ignore, fullscreen/restore/decline via request-display-mode). Story mocks gain sendHostContextChange/addEventListener. Closes #1568 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw * web: honor ui/download-file with confirmation and http(s) allowlist createAppBridgeFactory now advertises the `downloadFile` host capability and attaches an `ondownloadfile` handler: - Requires a host-mediated confirmation (window.confirm) listing the requested file(s); a declined prompt or an empty payload returns isError without acting. - Inline EmbeddedResources (text or base64 blob) are written to disk via the shared downloadBlob object-URL anchor; base64 is decoded via base64ToBytes. - ResourceLinks are opened in a new tab, restricted to http(s) by the shared isHttpUrl allowlist (javascript:/data:/file:/malformed are rejected). - Labels shown in the confirmation are sanitized (control + format chars stripped, length clamped) so a server-supplied filename/URI can't inject newlines or reflow the prompt. - Partial-batch success is surfaced (isError only when nothing downloaded), with skipped items warned. Adds factory tests for the full download surface (text/blob/link, decline, empty, throw, non-http rejection, label sanitization/clamping, partial batch). Closes #1569 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw * web: surface ui/message and app logging in panels below the running app AppRenderer: - New `onMessage` prop backs an `onmessage` bridge handler that surfaces the view's ui/message content and returns the spec-required empty result; with no handler the submission is declined (isError). - New `onLog` prop forwards MCP `notifications/message` log entries via the bridge `loggingmessage` event, honoring the advertised `logging` capability. AppsScreen: - Adds `messages`/`appLogs` state (cleared with the reported height on select/open/close/back via resetAppChannels) and `handleMessage`/`handleLog`. - Renders a "Messages from app" panel (data-testid="apps-messages", reusing ContentViewer) and a default-expanded collapsible "App logs" panel (data-testid="apps-logs", reusing LogLevelBadge) with logger names and a Clear button, both as pinned panels below the running widget. Adds AppRenderer tests (log forward + no-handler no-throw, ui/message route/empty-result/decline) and AppsScreen tests (message log render + empty result + clear-on-close, log panel expand/collapse/clear). Closes #1570 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw * web: stage partial input — replay tool-input-partial before final tool input AppRenderer: - New `partialInputs` prop (ordered fragments), snapshotted into pendingPartials at bridge-build time (read via a ref so prop churn never rebuilds the iframe) and cleared on dispose. flushPending replays them via bridge.sendToolInputPartial BEFORE the complete tool-input, per spec. AppsScreen: - New `partialStages` state + `handleStagePartialInput` (snapshots current form values). resetAppChannels gains a `keepPartials` option so Open App preserves the staged fragments (they're consumed by the renderer) while select/close/ back clear them. - Adds a "Stage partial input" control (fielded apps only) with a staged count and "Clear staged", wired to AppRenderer's `partialInputs`. Adds AppRenderer tests (ordered replay before tool-input; none when omitted) and AppsScreen tests (stage/count/clear gated on fields; staged partials survive Open and are replayed). Closes #1571 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw * web: app lifecycle status + machine-readable data attributes on the Apps screen AppRenderer: - New AppRendererStatus ("loading" | "ready" | "error") + onAppStatusChange callback. Fires "loading" at the start of every (re)build, "ready" on the view's initialized signal, and "error" on a factory throw/rejection. AppsScreen: - appStatus/appError state; handleAppError captures the reason locally and forwards to the parent onError. resetAppChannels clears both. - Surfaces the contract as data-testid="apps-form" carrying data-app-status and data-app-error, renders an error panel (data-testid="apps-error") in place of the blank frame, and tags the stage button data-testid="apps-stage". - AppDetailPanel's Open App button gains data-testid="open-app". App.tsx: - Wires the bridge factory's onResourceError to a red toast so a malformed/404 UI resource is no longer console-only (the renderer separately drives data-app-status, so a driver times out on never-reaching-ready and reads it). Documents the data-testid/data-* automation contract in clients/web/README.md. Adds AppRenderer status-transition tests and AppsScreen status/error-panel tests. Closes #1572 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw * review: warn when server sandbox drops allow-scripts + revert incidental lockfile churn Addresses the claude[bot] review on PR #1650: - sandbox_proxy.html: log a console.warn when a server-supplied `sandbox` value lacks `allow-scripts` after the `allow-same-origin` strip, so a fail-closed (app-never-runs) sandbox is diagnosable instead of silent. - Restore clients/{cli,launcher,tui,web}/package-lock.json to their v2/main state — the earlier churn (@emnapi/@napi-rs optional deps, a dropped version field) was incidental `npm install` regeneration unrelated to the sandbox change. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw * review: document CSP-vs-permissions filtering asymmetry (PR #1652) Address the claude[bot] review's finding #2: add a comment explaining why `csp` is injection-filtered (approveCspSources) while `permissions` is passed through verbatim — permissions is a structured boolean object consumed only by the sandbox proxy's buildAllowAttribute() (fixed key→token mapping), so it never reaches the iframe sandbox/allow attribute as raw text. No logic change. Also merges v2/1565-sandbox-hardening (review fixes) up the stack. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw * test: lock in per-app HOST_CAPABILITIES isolation (PR #1652 round 2) Add a test asserting the shared HOST_CAPABILITIES.sandbox stays undefined after a sandboxready run sets hostCapabilities.sandbox — guards against a silent regression if the per-app spread copy were ever dropped. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw * review: gate theme observer on initialized + document seed/dep assumptions (PR #1654) Address the claude[bot] review: - Finding #1 (applied): gate the theme+styles MutationObserver on the view's `initialized` signal, matching the container/displayMode pushes — a theme flip in the construction→handshake window no longer races ui/initialize. The factory seeds the construction-time theme, and the first post-init flip carries the current value. Added tests: pre-init flip is dropped; the live flip test now completes the handshake first. - Finding #2 (applied, comment): note in snapshotHostContext that the `displayMode: "inline"` seed assumes inline-at-open (AppsScreen always opens inline; #1568's displayMode push carries transitions). - Finding #4 (applied, comment): explain why `containerRef` is a build-effect dep yet doesn't force a rebuild (sameInputs ignores it; read lazily). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw * review: guard 0-height size reports, distinct RendererFrame, shared payload type (PR #1656) Address the claude[bot] review: - #1 (applied): handleSizeChange ignores a non-positive height — a view's ResizeObserver can transiently fire 0 pre-layout/teardown, which would collapse the frame; mirrors AppRenderer's 0×0 skip. Added a test. - #2 (applied): reinstate a distinct `RendererFrame` constant for the inner content-sized box so its role reads clearly vs. the outer RendererContainer. - #3 (declined + comment): keep carrying `appHeight` across maximize→restore — clearing it would flash the frame to full-card height until the next size-changed; the carry-over restores at the prior size. Documented. - #4 (applied): type handleSizeChange's param as AppBridgeEventMap["sizechange"] so the payload shape has a single source of truth with AppRenderer's prop. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw * review: distinguish links, cap batch size, comment truncation (PR #1657) Address the claude[bot] review: - #1 (applied): the confirmation now reads "download or open N item(s)" and resource_links are prefixed "↗" in the summary, so a link that opens in a tab is distinguishable from an embedded file that saves to disk. - #2 (applied): cap a single ui/download-file batch at MAX_DOWNLOAD_ITEMS (20); an oversized batch is rejected (isError) before the prompt, with a warn. - #3 (declined): can't check window.open's return value to detect a blocked popup — we pass `noopener`, and per spec window.open returns null even on a successful noopener open, so null can't distinguish blocked from succeeded. - #4b (applied, comment): note the start-of-label truncation is intentional so a link's scheme+host stays visible for the trust decision. Adds tests: oversized batch rejected without confirming/acting; resource-link ↗ prefix in the summary. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw * review: skip embedded resource with neither text nor blob (PR #1657 round 2) Address the round-2 observation: an untrusted EmbeddedResource carrying neither `blob` nor a string `text` previously produced `new Blob([undefined])` — a file containing the literal text "undefined", counted as success. Now such an item is skipped (returns false, like a rejected link), so isError reflects it. Added a test asserting no object URL is created and the batch reports isError. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw * review: type-honest formatLogData, cap channels, log-panel a11y (PR #1658) Address the claude[bot] review: - #1 (applied): formatLogData coalesces JSON.stringify(undefined) → "" so the `: string` return type is honest for a data-less log. - #2 (applied): cap retained messages/logs at MAX_APP_CHANNEL_ENTRIES (500) via appendCapped — a chatty widget no longer grows the panels/DOM without bound between Clear/close; oldest entries drop. - #3 (declined): keep the Messages panel without a Clear/collapse — messages are sparser and already cleared on close/switch; keeping it simple. - #4 (applied): the App-logs toggle now has aria-controls pointing at the Collapse region (id="apps-logs-region") for assistive tech. Adds tests: no-data log renders (coalesce branch), 501 logs cap at 500 (oldest dropped), aria-controls assertion. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw * review: stable id key for Messages panel (PR #1658 round 2) Address the round-2 nit: give AppMessage a stable `id` (assigned via nextMessageIdRef in handleMessage) and key the Messages panel on it instead of the array index — so the appendCapped front-drop can't renumber keys once a run exceeds the cap, matching AppLogEntry's treatment. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw * review: note partialStages intentionally survives handleOpen (PR #1659) Address the claude[bot] review's observation #2: add a comment at the handleOpen call site clarifying that partialStages is intentionally NOT cleared there — AppRenderer snapshots the fragments into its own pendingPartialsRef at build time, and the staging UI only renders while not running, so the surviving state is invisible until the next reset drains it. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw * review: align error-panel docs with behavior + gate data-app-error on running (PR #1660) Address the claude[bot] review: - #1 (applied, docs): the error panel renders BELOW the (blank) frame, not in place of it — the renderer stays mounted so a factory error still shows the reason alongside the iframe (gating the frame off on error would drop the auto-launched iframe an integration test relies on). Reworded the README row and the test comment to say "below the frame"; added a code comment. - #2 (applied): gate data-app-error on `running` (like data-app-status) so the two attributes can't desync if a future path sets an error without running. - #3 (no change): resource-read failures staying at data-app-status="loading" (toast only) is intentional and documented. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw --------- Co-authored-by: Claude Opus 4.8 (1M context) --- clients/web/README.md | 17 + clients/web/server/sandbox-controller.ts | 13 + clients/web/src/App.tsx | 13 + .../AppRenderer/AppRenderer.stories.tsx | 3 + .../elements/AppRenderer/AppRenderer.test.tsx | 619 +++++++++++++++++- .../elements/AppRenderer/AppRenderer.tsx | 258 +++++++- .../createAppBridgeFactory.test.ts | 529 ++++++++++++++- .../AppRenderer/createAppBridgeFactory.ts | 258 ++++++-- .../elements/AppRenderer/hostContext.ts | 5 + .../groups/AppDetailPanel/AppDetailPanel.tsx | 1 + .../screens/AppsScreen/AppsScreen.stories.tsx | 2 + .../screens/AppsScreen/AppsScreen.test.tsx | 458 ++++++++++++- .../screens/AppsScreen/AppsScreen.tsx | 477 +++++++++++++- clients/web/src/lib/sandbox-csp.test.ts | 2 +- .../server/sandbox-controller.test.ts | 9 + clients/web/static/sandbox_proxy.html | 100 ++- 16 files changed, 2631 insertions(+), 133 deletions(-) diff --git a/clients/web/README.md b/clients/web/README.md index 1bff70e3..329317e4 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 f007b405..92419c4e 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 48295bcf..255d71e3 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 2ee718b3..2ff8c0eb 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 8a10a82d..44088632 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 54d9f684..8e061c68 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 54dd6020..45aa3cde 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,13 +177,81 @@ 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 () => { const readResource = vi.fn(); const factory = createAppBridgeFactory({ @@ -182,7 +268,71 @@ 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("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, @@ -193,19 +343,8 @@ describe("createAppBridgeFactory", () => { bridge.emit("sandboxready"); await flush(); expect(bridge.sendSandboxResourceReady).not.toHaveBeenCalled(); - }); - - it("swallows a UI resource that has no text content", async () => { - const readResource = vi.fn().mockResolvedValue(uiResource(undefined)); - const factory = createAppBridgeFactory({ - getClient: () => fakeClient, - readResource, - }); - await factory(makeIframe(), tool); - const bridge = bridgeInstances[0]; - bridge.emit("sandboxready"); - await flush(); - expect(bridge.sendSandboxResourceReady).not.toHaveBeenCalled(); + 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 079d2665..4b59b4f6 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); - await bridge.sendSandboxResourceReady({ - html, + // 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: 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 9ea6f8f0..eca56882 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 40e56a6c..09c25a1f 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 a517e8fe..319dc7f2 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 9157918b..7c9c0dcf 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 127f43c6..dd85b02a 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 3cd471c2..de7d6352 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