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:
-
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.
-
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.
-
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 yet — grep -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:
sendToolInput → tools/call on the InspectorClient, response routed back via sendToolResult.
sendToolResult → postMessage 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:
- Fetches the app's UI resource via
resources/read against tool._meta.ui.resourceUri.
- 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).
- 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
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:
-
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.
-
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.
-
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.
-
Renderer: v1 at client/src/components/AppRenderer.tsx and v1.5 at clients/web/src/components/AppRenderer.tsx — AppsTab 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.
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.tsxare still stubs:STUB_SANDBOX_PATH = \"about:blank\"(App.tsx:100) — passed intoAppsScreenassandboxPath(App.tsx:895) and ultimately becomes the iframesrcinsideAppRenderer(AppRenderer.tsx:142). The iframe loadsabout:blankinstead of the inspector'ssandbox_proxy.html, so the double-iframe sandbox architecture never bootstraps and no app HTML is ever injected.stubBridgeFactory(App.tsx:101-108) — everyAppBridgemethod is a no-op:Even if the sandbox iframe were loaded, no input or tool result would ever cross the bridge.
onOpenApp={todoNoop}(App.tsx:947) — when the user clicks Open App, nothing callstools/callon the activeInspectorClientand nothing reads the app's UI resource (tool._meta.ui.resourceUri) viaresources/readto push HTML into the sandbox via theui/notifications/sandbox-resource-readymessage the sandbox proxy expects (clients/web/static/sandbox_proxy.html:108-159).The header comment at
App.tsx:93-99openly flags this:The backend half is already done:
createSandboxControllermountssandbox_proxy.htmlat/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 readssandboxUrlfrom that payload yet —grep -rn sandboxUrl clients/web/srcreturns no matches outside tests/stories.What the "App Running" and "App Maximized" stories actually show
Both stories use:
…as
sandboxPath, andokBridgeFactory(a no-opcreateMockBridge()) for the bridge (AppsScreen.stories.tsx:13, 15-23, 25). When the play function clicks Open App,AppRendereris mounted and its iframe loads the data URL — but that data URL is just<title>Mock Sandbox</title>with no<body>content and nocolor-scheme: darkmeta.So the iframe renders:
color-scheme: dark(unlikesandbox_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
sandboxUrlfrom the initial-config payloadThe Vite/prod backends already include
sandboxUrlin the response served alongside the SPA (vite-hono-plugin.ts:65). Add the client-side consumer:sandboxUrlfrom the initial-config payload at app startup and thread it down toInspectorView/AppsScreenassandboxPath.STUB_SANDBOX_PATHinApp.tsxwith the resolved URL. IfsandboxUrlis missing from the payload (legacy backend / SPA built without the controller), fall back to disabling the Apps screen with a clear message — not toabout:blank, since that silently looks broken.2. Real
BridgeFactoryReplace
stubBridgeFactoryinApp.tsxwith a factory that, given the freshly mounted iframe, builds anAppBridgewired to the activeInspectorClient:sendToolInput→tools/callon the InspectorClient, response routed back viasendToolResult.sendToolResult→postMessageto the iframe so the inner sandboxed UI receives the call'sCallToolResult.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
AppBridgeis the reference for the postMessage contract and theui/notifications/sandbox-resource-readypayload shape; the sandbox proxy expects{ html, sandbox, permissions }(sandbox_proxy.html:143-153).3. Real
onOpenApphandlerReplace
onOpenApp={todoNoop}(App.tsx:947) with a handler that:resources/readagainsttool._meta.ui.resourceUri.ui/notifications/sandbox-resource-readypostMessage to the iframe with the resource'shtml,sandbox, andpermissionsso the inner iframe loads the content (sandbox_proxy.html:108, 143-159).tools/callwith the provided form values via the bridge, so the running app receives its input viasendToolInput.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
PLACEHOLDER_SANDBOXwith a self-contained data URL (or local fixture HTML) that renders a visible, themed placeholder (e.g. a centered "Mock app — Weather Widget" text withcolor-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.sendToolInputback as asendToolResultso the running-state interaction is observable innpm run test:storybook.Acceptance criteria
AppsScreenafter clicking Open App.App.tsx:93-99is removed;STUB_SANDBOX_PATHandstubBridgeFactoryare gone.sandboxUrlfrom the initial-config payload is consumed by the client and threaded through assandboxPath.sandboxUrlis unavailable, the Apps screen renders a clear empty-state explaining MCP Apps are unavailable (not a silent blank iframe).tools/callwith form values is issued when Open App is clicked, the response is delivered to the app via the bridge, andnotifications/tools/list_changedcontinues to refresh the sidebar (listChangedprop).hasInputFields(tool) === false) still auto-launch on selection, matching the existing UX.AppRunningandAppRunningMaximizedshow a visible themed placeholder in both light and dark mode.AppsScreenwith a fakeInspectorClientand a real bridge against a controlled iframe fixture, asserting input round-trips.Out of scope
tools/callround-trip (e.g. streaming resource updates) — track separately if needed.sandbox_proxy.html's existingpermissionsfield 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:
Flat file:
server/static/sandbox_proxy.htmlin v1, andclients/web/static/sandbox_proxy.htmlin v1.5 — the same kind of double-iframe proxy v2 already has atclients/web/static/sandbox_proxy.html.Endpoint:
server/src/index.ts:932— Express handler atGET /sandboxthatreadFileSyncs the flat file and returns it withCache-Control: no-cache, no-store, max-age=0(behindsandboxRateLimiter). V2 has the equivalent atclients/web/server/sandbox-controller.tsbut on a separate port via its own HTTP server.Client wiring:
client/src/App.tsx:1618— App passessandboxPath={${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-2037reads asandboxUrl(set fromdata.sandboxUrlon the config payload atApp.tsx:1079) and passes it assandboxPathto<AppsTab>. There's no fundamentally novel "surface a sandbox URL" problem for v2 — it's already a solved pattern on v1.5.Renderer: v1 at
client/src/components/AppRenderer.tsxand v1.5 atclients/web/src/components/AppRenderer.tsx—AppsTabpassessandboxPathinto this component, which then hands it toAppRenderer(aliasedMcpUiAppRenderer) imported from@mcp-ui/client:This is the load-bearing detail: neither v1 nor v1.5 implement the iframe /
AppBridge/tools/callplumbing themselves — both delegate the whole thing to@mcp-ui/client'sAppRenderer, which takes the SDKClient, 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.tsxdoes not consume@mcp-ui/client— it owns a hand-rolled iframe +BridgeFactoryabstraction. That divergence is almost certainly why v1/v1.5 apps don't run here: this issue's work item #2 ("realBridgeFactory") is reinventing what@mcp-ui/clientalready implements upstream and what v1.5 is already shipping to users. The pragmatic fix is to pull@mcp-ui/client'sAppRendererin (the same way v1 and v1.5 do), feed it the activeInspectorClient's underlying SDKClientplus a URL pointing at v2's existing/sandboxcontroller endpoint, and delete the hand-rolled bridge stubs inApp.tsx. TheBridgeFactoryabstraction in v2'sAppRenderermay not need to exist at all.