Skip to content

AppsScreen: render running MCP Apps end-to-end (sandbox URL, live AppBridge, tools/call wiring) #1370

@cliffhall

Description

@cliffhall

Summary

The Apps screen builds a complete UI shell — sidebar of apps, input form per app, header with Maximize/Restore/Close — but clicking Open App does not render anything. The user is left looking at an empty content card.

MCP Apps that work in the v1 Inspector do not work here.

Root cause

Three pieces of plumbing in clients/web/src/App.tsx are still stubs:

  1. STUB_SANDBOX_PATH = \"about:blank\" (App.tsx:100) — passed into AppsScreen as sandboxPath (App.tsx:895) and ultimately becomes the iframe src inside AppRenderer (AppRenderer.tsx:142). The iframe loads about:blank instead of the inspector's sandbox_proxy.html, so the double-iframe sandbox architecture never bootstraps and no app HTML is ever injected.

  2. stubBridgeFactory (App.tsx:101-108) — every AppBridge method is a no-op:

    sendToolInput: async () => {},
    sendToolResult: async () => {},
    sendToolCancelled: async () => {},
    teardownResource: async () => ({}),
    close: async () => {},

    Even if the sandbox iframe were loaded, no input or tool result would ever cross the bridge.

  3. onOpenApp={todoNoop} (App.tsx:947) — when the user clicks Open App, nothing calls tools/call on the active InspectorClient and nothing reads the app's UI resource (tool._meta.ui.resourceUri) via resources/read to push HTML into the sandbox via the ui/notifications/sandbox-resource-ready message the sandbox proxy expects (clients/web/static/sandbox_proxy.html:108-159).

The header comment at App.tsx:93-99 openly flags this:

The dev backend serves sandbox_proxy.html on the sandbox controller port; the factory will eventually wrap the SDK client. For now neither is wired (the Apps tab uses these props but does not yet round-trip tool input through a live bridge — that's a follow-up alongside the AppRenderer integration).

The backend half is already done: createSandboxController mounts sandbox_proxy.html at /sandbox (clients/web/server/sandbox-controller.ts) and the Vite plugin exposes the resulting URL on the initial-config payload (clients/web/server/vite-hono-plugin.ts:65). Nothing on the client side reads sandboxUrl from that payload yetgrep -rn sandboxUrl clients/web/src returns no matches outside tests/stories.

What the "App Running" and "App Maximized" stories actually show

Both stories use:

const PLACEHOLDER_SANDBOX = \"data:text/html,<title>Mock%20Sandbox</title>\";

…as sandboxPath, and okBridgeFactory (a no-op createMockBridge()) for the bridge (AppsScreen.stories.tsx:13, 15-23, 25). When the play function clicks Open App, AppRenderer is mounted and its iframe loads the data URL — but that data URL is just <title>Mock Sandbox</title> with no <body> content and no color-scheme: dark meta.

So the iframe renders:

  • Light mode: the iframe's default white background blends with the surrounding card, looking like an empty panel.
  • Dark mode: a white rectangle, because the data URL HTML doesn't opt into color-scheme: dark (unlike sandbox_proxy.html, which has <meta name=\"color-scheme\" content=\"light dark\">).

These stories exercise the AppsScreen state machine reaching the running/maximized states (sidebar collapse, header swap, Maximize/Restore button). They are intentionally not rendering real app content — they document the screen's UI states, not the runtime integration. The fact that the resulting visual is unhelpful (a blank/white iframe) is a reasonable signal that the stories should be updated alongside this work — see below.

Proposed change

1. Surface sandboxUrl from the initial-config payload

The Vite/prod backends already include sandboxUrl in the response served alongside the SPA (vite-hono-plugin.ts:65). Add the client-side consumer:

  • Read sandboxUrl from the initial-config payload at app startup and thread it down to InspectorView / AppsScreen as sandboxPath.
  • Replace STUB_SANDBOX_PATH in App.tsx with the resolved URL. If sandboxUrl is missing from the payload (legacy backend / SPA built without the controller), fall back to disabling the Apps screen with a clear message — not to about:blank, since that silently looks broken.

2. Real BridgeFactory

Replace stubBridgeFactory in App.tsx with a factory that, given the freshly mounted iframe, builds an AppBridge wired to the active InspectorClient:

  • sendToolInputtools/call on the InspectorClient, response routed back via sendToolResult.
  • sendToolResultpostMessage to the iframe so the inner sandboxed UI receives the call's CallToolResult.
  • sendToolCancelled → cancellation notification through the same channel.
  • teardownResource / close → tear down any per-call subscriptions and close the bridge transport.

The v1 implementation of the AppBridge is the reference for the postMessage contract and the ui/notifications/sandbox-resource-ready payload shape; the sandbox proxy expects { html, sandbox, permissions } (sandbox_proxy.html:143-153).

3. Real onOpenApp handler

Replace onOpenApp={todoNoop} (App.tsx:947) with a handler that:

  1. Fetches the app's UI resource via resources/read against tool._meta.ui.resourceUri.
  2. Sends a ui/notifications/sandbox-resource-ready postMessage to the iframe with the resource's html, sandbox, and permissions so the inner iframe loads the content (sandbox_proxy.html:108, 143-159).
  3. Then issues the tools/call with the provided form values via the bridge, so the running app receives its input via sendToolInput.

The exact ordering must match the v1 Inspector's behavior so that apps which work in v1 (the user's confirmation case) also work here.

4. Story / fixture updates

  • Replace PLACEHOLDER_SANDBOX with a self-contained data URL (or local fixture HTML) that renders a visible, themed placeholder (e.g. a centered "Mock app — Weather Widget" text with color-scheme: light dark) so the App Running / App Maximized stories actually show something in both light and dark themes and document the running state visually rather than as a blank rectangle.
  • Add at least one story that wires a fake bridge which echoes sendToolInput back as a sendToolResult so the running-state interaction is observable in npm run test:storybook.

Acceptance criteria

  • An MCP App that works in the v1 Inspector renders inside AppsScreen after clicking Open App.
  • Header comment block at App.tsx:93-99 is removed; STUB_SANDBOX_PATH and stubBridgeFactory are gone.
  • sandboxUrl from the initial-config payload is consumed by the client and threaded through as sandboxPath.
  • If sandboxUrl is unavailable, the Apps screen renders a clear empty-state explaining MCP Apps are unavailable (not a silent blank iframe).
  • tools/call with form values is issued when Open App is clicked, the response is delivered to the app via the bridge, and notifications/tools/list_changed continues to refresh the sidebar (listChanged prop).
  • No-input apps (hasInputFields(tool) === false) still auto-launch on selection, matching the existing UX.
  • Close → tears down the bridge + iframe and returns to the no-selection state with no leaked listeners or postMessage handlers.
  • AppsScreen stories AppRunning and AppRunningMaximized show a visible themed placeholder in both light and dark mode.
  • New integration coverage for the runtime path: a test that drives AppsScreen with a fake InspectorClient and a real bridge against a controlled iframe fixture, asserting input round-trips.

Out of scope

  • Server-driven UI updates beyond the initial tools/call round-trip (e.g. streaming resource updates) — track separately if needed.
  • OAuth / per-app permission UI beyond what sandbox_proxy.html's existing permissions field already supports.

v1 + v1.5 reference implementation (likely the missing piece)

Both v1 and v1.5 wire this with a tight, well-defined chain that v2 hasn't reproduced yet. The same approach is shipping and working in the v1.5 web client today — v2 is the outlier here, not v1. The shape:

  1. Flat file: server/static/sandbox_proxy.html in v1, and clients/web/static/sandbox_proxy.html in v1.5 — the same kind of double-iframe proxy v2 already has at clients/web/static/sandbox_proxy.html.

  2. Endpoint: server/src/index.ts:932 — Express handler at GET /sandbox that readFileSyncs the flat file and returns it with Cache-Control: no-cache, no-store, max-age=0 (behind sandboxRateLimiter). V2 has the equivalent at clients/web/server/sandbox-controller.ts but on a separate port via its own HTTP server.

  3. Client wiring: client/src/App.tsx:1618 — App passes sandboxPath={${getMCPProxyAddress(config)}/sandbox} straight to <AppsTab>. The path is built from the proxy address the client already knows about. v1.5 follows the same pattern: App.tsx:2036-2037 reads a sandboxUrl (set from data.sandboxUrl on the config payload at App.tsx:1079) and passes it as sandboxPath to <AppsTab>. There's no fundamentally novel "surface a sandbox URL" problem for v2 — it's already a solved pattern on v1.5.

  4. Renderer: v1 at client/src/components/AppRenderer.tsx and v1.5 at clients/web/src/components/AppRenderer.tsxAppsTab passes sandboxPath into this component, which then hands it to AppRenderer (aliased McpUiAppRenderer) imported from @mcp-ui/client:

    import {
      AppRenderer as McpUiAppRenderer,
      type McpUiHostContext,
      type RequestHandlerExtra,
    } from \"@mcp-ui/client\";
    
    <McpUiAppRenderer
      client={mcpClient}
      toolName={tool.name}
      hostContext={hostContext}
      toolInput={toolInput}
      toolResult={normalizedToolResult}
      sandbox={{ url: new URL(sandboxPath, window.location.origin) }}
      onError={(err) => setError(err.message)}
    />

    This is the load-bearing detail: neither v1 nor v1.5 implement the iframe / AppBridge / tools/call plumbing themselves — both delegate the whole thing to @mcp-ui/client's AppRenderer, which takes the SDK Client, the tool name, the input, the result, and the sandbox URL, and runs the bridge internally.

V2's clients/web/src/components/elements/AppRenderer/AppRenderer.tsx does not consume @mcp-ui/client — it owns a hand-rolled iframe + BridgeFactory abstraction. That divergence is almost certainly why v1/v1.5 apps don't run here: this issue's work item #2 ("real BridgeFactory") is reinventing what @mcp-ui/client already implements upstream and what v1.5 is already shipping to users. The pragmatic fix is to pull @mcp-ui/client's AppRenderer in (the same way v1 and v1.5 do), feed it the active InspectorClient's underlying SDK Client plus a URL pointing at v2's existing /sandbox controller endpoint, and delete the hand-rolled bridge stubs in App.tsx. The BridgeFactory abstraction in v2's AppRenderer may not need to exist at all.

Metadata

Metadata

Assignees

No one assigned

    Labels

    v2Issues and PRs for v2

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions