Repository navigation
Description
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 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: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 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-99 openly flags this:
The dev backend serves
sandbox_proxy.htmlon 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(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 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
sandboxUrlfrom the initial-config payload at app startup and thread it down toInspectorView/AppsScreenassandboxPath. - Replace
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 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/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 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/readagainsttool._meta.ui.resourceUri. - Sends a
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). - Then issues the
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
- Replace
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. - Add at least one story that wires a fake bridge which echoes
sendToolInputback as asendToolResultso the running-state interaction is observable innpm run test:storybook.
Acceptance criteria
- An MCP App that works in the v1 Inspector renders inside
AppsScreenafter clicking Open App. - Header comment block at
App.tsx:93-99is removed;STUB_SANDBOX_PATHandstubBridgeFactoryare gone. -
sandboxUrlfrom the initial-config payload is consumed by the client and threaded through assandboxPath. - If
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). - 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
AppRunningandAppRunningMaximizedshow a visible themed placeholder in both light and dark mode. - New integration coverage for the runtime path: a test that drives
AppsScreenwith a fakeInspectorClientand a real bridge against a controlled iframe fixture, asserting input round-trips.
Out of scope
- Server-driven UI updates beyond the initial
tools/callround-trip (e.g. streaming resource updates) — track separately if needed. - OAuth / per-app permission UI beyond what
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: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/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.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.
Activity
feat(apps): render running MCP Apps end-to-end (#1370) (#1403)