// `virtual:executor-mcp-apps-shell-html` is supplied by `mcpAppsShellAsset` // (`@executor-js/mcp-apps-shell/vite`). Referenced explicitly — and first, as // triple-slash directives are only honored above the imports — so the ambient // declaration travels with this file into consumers that bundle it; a // consumer's tsconfig `include` covers only its own sources. /// import { useCallback, useEffect, useRef, useState } from "react"; import { AppBridge, PostMessageTransport } from "@modelcontextprotocol/ext-apps/app-bridge"; import type { McpUiHostContext } from "@modelcontextprotocol/ext-apps"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import type { ArtifactRendererProps } from "@executor-js/react/api/artifact-renderer"; import shellHtmlUrl from "virtual:executor-mcp-apps-shell-html"; // From `./preview-capture`, NOT from `./shell-app`: that module imports the // shell build's virtual modules (`virtual:executor-tailwind-browser` and // friends), which do not exist in the console's bundle — importing a constant // from it drags them in and the artifact renderer fails to load entirely. import { PREVIEW_CAPTURE_HOST_CONTEXT_KEY, SAVE_ARTIFACT_PREVIEW_TOOL } from "./preview-capture"; // Same import-free-module reasoning as `./preview-capture`: the vocabulary for // "the artifact is on screen now", shared by the shell document that sends it // and this host, which is the one host that holds a loading surface until it // arrives. import { ARTIFACT_RENDERED_TOOL, RENDER_SIGNAL_HOST_CONTEXT_KEY } from "./render-signal"; // Same reasoning as `./preview-capture`: a module with no build-graph imports of // its own, so both the host half (here) and the app half (`shell-app.tsx`) can // agree on the vocabulary without either dragging in the other's virtual // modules. import { FILL_DISPLAY_MODE, INLINE_DISPLAY_MODE } from "./display-mode"; /** * Hosts the MCP-Apps shell on the console's artifact page. * * The shell is NOT a React component this page mounts. It is a self-contained * DOCUMENT — the very bytes served as the `ui://executor/shell.html` resource — * loaded into a sandboxed iframe, with this component implementing the HOST half * of the MCP Apps protocol against it. The artifact page is therefore an MCP * Apps client like any other, and the shell runs in one configuration * everywhere. * * That is not architectural purity for its own sake; mounting `McpAppsShell` * inline was actively broken: * * - It imports `@tailwindcss/browser` and its own `globals.css` at IMPORT * scope, and it themes by toggling `document.documentElement`. Inline, all * of that landed on the CONSOLE document — the shell's palette (a teal * `--primary`) overwrote the console's strictly-grayscale tokens for the * rest of the session, and its runtime Tailwind JIT kept mutating the page. * - Its inner renderer reports content height over the MCP-Apps resize * protocol, which expects a host frame to consume it. With no host, nothing * did, so tall artifacts clipped mid-viewport. * * Both symptoms are one root cause — a document-scoped program mounted without * its document — and both are fixed by giving it one. * * This module is still BROWSER-ONLY (it builds a postMessage bridge against a * live iframe), so the `ArtifactRendererProvider` loader seam is unchanged: apps * register it through a dynamic `import()` that only ever resolves in the * browser, and the page holds it behind `ClientOnly` + `Suspense`. It stays the * DEFAULT export because the seam hands the loader straight to `React.lazy`. */ /** What the page passes as `host` — see `createHttpShellHost` in * `@executor-js/react/api/shell-host`. Declared structurally because that * package depends on THIS one for its component barrel, so importing its type * here would close a package cycle turbo rejects outright. */ type HttpShellHost = { readonly callServerTool: (params: { name: string; arguments?: Record; }) => Promise; readonly getHostContext: () => { readonly theme: "light" | "dark" } | undefined; readonly openLink: (params: { url: string }) => Promise; /** Store a settled-render snapshot as this artifact's gallery preview. */ readonly savePreview: (artifactId: string, preview: string) => void; }; /** The frame height before the app has reported its content size, in the inline * fallback path. Tall enough that the shell's own loading state is not itself * clipped. */ const INITIAL_FRAME_HEIGHT = 320; /** * A floor under the measured viewport. * * The container is measured, not guessed, so this only guards the degenerate * readings — a container measured while the page is still laying out, or a * window dragged to a sliver. Below this there is no room for a header and a * scroll region both, and a frame of a few pixels reads as a broken page. */ const MIN_FRAME_HEIGHT = 160; /** * The console's active theme, resolved the way the console's own CSS resolves * it: an explicit `.dark` class wins, otherwise the OS preference. */ const readDocumentTheme = (): "light" | "dark" => { if (document.documentElement.classList.contains("dark")) return "dark"; return globalThis.window.matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark" : "light"; }; /** * What this host tells the app about the surface it has. * * The detail page is the one place an artifact is not embedded in a flow of * other content — it IS the page — so it advertises the spec's `fullscreen` * mode and hands over a FIXED `containerDimensions.height`. Together those two * fields are the whole negotiation: the mode says "you own this surface", the * fixed height says how much of it there is. See `./display-mode` for why * `fullscreen` rather than a mode of our own, and why a fixed `height` rather * than a `maxHeight`. * * `availableDisplayModes` lists both, truthfully: this host can render an * artifact either way, and an app that asks to go back to `inline` is asking * for something the page could honor. * * Before the container has been measured (`viewport === null`) the page has not * yet laid out, and claiming a surface whose size is unknown would have the app * resolve `height: 100%` against nothing. So until then this is an ordinary * inline host, which is also what makes the mode switch a no-op for a viewport * that never resolves. */ const hostContextFor = (theme: "light" | "dark", viewport: number | null): McpUiHostContext => ({ theme, displayMode: viewport === null ? INLINE_DISPLAY_MODE : FILL_DISPLAY_MODE, availableDisplayModes: [INLINE_DISPLAY_MODE, FILL_DISPLAY_MODE], platform: "web", ...(viewport === null ? {} : { containerDimensions: { height: viewport } }), // The console is the one host with somewhere to put a preview, so it is the // one host that asks for one. Carried on the spec's open index signature, // which is what it exists for; every other host omits it and the shell // skips the capture entirely. [PREVIEW_CAPTURE_HOST_CONTEXT_KEY]: true, // The console holds ONE skeleton from navigation until the artifact paints, // so it is the one host that needs telling when that is. See // `./render-signal` for why the shell cannot infer it and why no size report // is the same fact. [RENDER_SIGNAL_HOST_CONTEXT_KEY]: true, }); export default function ArtifactShell(props: ArtifactRendererProps) { const frameRef = useRef(null); const containerRef = useRef(null); /** * The measured height of the page's content area, or `null` before the first * measurement. * * This — not the app's reported content height — is the frame's height. That * inversion is the fix: the artifact page gives the artifact a viewport and * holds it there, so the artifact's own `flex-1 min-h-0 overflow-auto` has * something to resolve against and its header can stay put while its table * scrolls underneath. */ const [viewport, setViewport] = useState(null); /** * The app's own reported content height — the inline story, kept for the case * where the container never resolves to a usable size (a host embedding this * component somewhere with no bounded height of its own). On the detail page * the observer resolves immediately and this is never read. */ const [contentHeight, setContentHeight] = useState(INITIAL_FRAME_HEIGHT); const [theme, setTheme] = useState<"light" | "dark">(() => readDocumentTheme()); // Read through refs inside the bridge: the bridge is built once per artifact, // and rebuilding it when the host identity changes would reload the iframe and // discard the rendered component. const hostRef = useRef(props.host as HttpShellHost); hostRef.current = props.host as HttpShellHost; const codeRef = useRef(props.code); codeRef.current = props.code; const artifactIdRef = useRef(props.artifactId); artifactIdRef.current = props.artifactId; // The page's "you can drop the skeleton now" callback, read through a ref for // the same reason as the host: the bridge is built once per artifact, and // capturing the prop would pin whichever function existed then. const onRenderedRef = useRef(props.onRendered); onRenderedRef.current = props.onRendered; // The bridge is built once per artifact and must see the CURRENT viewport, // both when it opens (so its initial host context is already the fill one) // and inside `onsizechange` (so it knows to ignore the report). const viewportRef = useRef(viewport); viewportRef.current = viewport; const bridgeRef = useRef(null); // Track the console's theme and forward it as host context, which is what // drives the shell's own `applyTheme` — inside the iframe's document, where it // belongs, rather than on the console's `documentElement`. useEffect(() => { const media = globalThis.window.matchMedia?.("(prefers-color-scheme: dark)"); const sync = () => setTheme(readDocumentTheme()); media?.addEventListener("change", sync); const observer = new MutationObserver(sync); observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] }); return () => { media?.removeEventListener("change", sync); observer.disconnect(); }; }, []); /** * Measure the surface the page has given this artifact, and keep measuring. * * A `ResizeObserver` on the container rather than a `window` resize listener: * the content area is a flex child of the console's own layout, so it changes * height for reasons that never touch the window — the sidebar collapsing, a * banner appearing above it. Observing the box that actually holds the frame * catches all of those and the window resize alike. * * `borderBoxSize` is read in preference to `contentRect` so a container that * ever gains padding or a border still reports the space the frame occupies. */ useEffect(() => { const container = containerRef.current; if (!container) return; const measure = (next: number) => { const bounded = Math.max(MIN_FRAME_HEIGHT, Math.floor(next)); // Guard the state write, not just the render: an observer that fires on // every sub-pixel reflow would otherwise push a new host context — and // therefore a `ui/notifications/host-context-changed` — into the frame // continuously while a user drags a window edge. setViewport((prev) => (prev === bounded ? prev : bounded)); }; const observer = new ResizeObserver((entries) => { const entry = entries[0]; if (!entry) return; const borderBox = entry.borderBoxSize?.[0]?.blockSize; measure(typeof borderBox === "number" ? borderBox : entry.contentRect.height); }); observer.observe(container); // Seed from the current layout rather than waiting for the observer's first // callback, so the very first host context the bridge is built with already // carries a viewport and the app never renders a frame in the wrong mode. measure(container.getBoundingClientRect().height); return () => observer.disconnect(); }, []); // Both the theme and the viewport travel on the same context, and // `setHostContext` diffs for us — it notifies the app only about fields that // actually changed, so a resize does not re-announce the theme. useEffect(() => { bridgeRef.current?.setHostContext(hostContextFor(theme, viewport)); }, [theme, viewport]); /** * Build and connect the bridge the moment the iframe ELEMENT mounts — as the * frame's `ref`, not its `onLoad`. * * This ordering is the whole correctness argument, so it is worth stating * exactly. `ui/initialize` is a fire-and-forget `postMessage`: the app's * `connect()` sends it once, ext-apps has no retry, no backoff and no * readiness handshake, and `AppBridge` buffers nothing. The host's transport * only begins listening inside `bridge.connect()`, whose last synchronous step * is `window.addEventListener("message", …)`. So a `ui/initialize` posted * while the host has no listener is delivered to a window that ignores it and * is gone for good — both sides then wait forever, which is the shell stuck on * "Connecting" (the app's request does eventually time out after the SDK's 60s * default, but the host has long since stopped being able to answer). * * Connecting in `onLoad` lost that race. The frame's `load` event fires AFTER * its document has been parsed and its module scripts have run, so the app * could — and on a cold, dev-served, 4.5MB document did — post `ui/initialize` * before the host was listening. It "worked" locally only because the two * landed within a few milliseconds of each other. * * A ref callback runs during the commit phase, synchronously after React has * inserted the element into the document and therefore before the browser has * fetched a single byte of `src`. `contentWindow` is already non-null there * (it is the initial `about:blank` window), and a same-origin navigation keeps * the browsing context's WindowProxy identity — so both the transport's * `postMessage` target and its `event.source` filter still refer to the shell * document once it replaces the placeholder. The listener is thus provably up * before any script in the frame can run, whatever the machine, cache state or * document size. This is also how sunpeak's host does it, which is why real * MCP-Apps hosts never saw this bug. * * The returned cleanup makes this callback the SOLE owner of the bridge's * lifecycle. Previously an effect keyed on `props.code` closed * `bridgeRef.current`, which is a second, unsynchronized owner: under a * StrictMode double-invoke or any re-render that reorders effects against * refs, that cleanup could close a bridge a later mount had just connected, * stranding the shell on "Connecting" for an entirely different reason. Now * each bridge is created and closed by the same attach/detach pair and can * only ever close itself. */ const attachFrame = useCallback((frame: HTMLIFrameElement | null): (() => void) | undefined => { frameRef.current = frame; const contentWindow = frame?.contentWindow; if (!contentWindow) return undefined; const bridge = new AppBridge( // No MCP client: the artifact page reaches the server over the ordinary // executions HTTP API, so every app-originated call is answered by the // handlers below rather than proxied onto an MCP connection. null, { name: "Executor Console", version: "1.0.0" }, { openLinks: {}, serverTools: {} }, { hostContext: hostContextFor(readDocumentTheme(), viewportRef.current) }, ); // Deliver the stored source the way a real host does after `create-artifact`: the // tool input, then the tool result carrying `structuredContent.code` — the // exact shape `renderedInAppResult` builds in the MCP host. The shell // already handles both, so it takes the same path here as it does under any // other MCP-Apps client, with no artifact-page-only branch. bridge.oninitialized = () => { const code = codeRef.current; const artifactId = artifactIdRef.current; void bridge .sendToolInput({ arguments: { code, artifactId } }) .then(() => bridge.sendToolResult({ content: [{ type: "text", text: "Rendered saved artifact." }], structuredContent: { code, artifactId }, }), ) .catch((error: unknown) => { console.error("[executor-console] Failed to deliver the artifact to the shell:", error); }); }; /** * The app keeps reporting its content height. In fill mode this host * deliberately does nothing with it. * * That is the clean division the mode buys: the HOST owns the mode, so the * app does not need a branch for "am I being measured" — it reports its size * truthfully in every mode, exactly as the protocol says, and a host that * has already decided the frame's height simply does not consume the report. * Acting on it here would undo the whole fix, since the app's content is now * as tall as the viewport it was given and growing the frame to match would * chase itself. * * Inline hosts — every chat client — are untouched: the frame grows, the * page scrolls, nothing clips. */ bridge.onsizechange = (params) => { if (viewportRef.current !== null) return; if (typeof params.height !== "number") return; setContentHeight(Math.max(MIN_FRAME_HEIGHT, Math.ceil(params.height))); }; bridge.oncalltool = (params) => { // The shell's preview upgrade is not a server tool; it is this host's own // call, answered here. Intercepted before the tool transport so it never // reaches `/executions` — a snapshot is not an execution and must not be // metered, approved or logged as one. if (params.name === SAVE_ARTIFACT_PREVIEW_TOOL) { const artifactId = params.arguments?.["artifactId"]; const preview = params.arguments?.["preview"]; if (typeof artifactId === "string" && typeof preview === "string") { hostRef.current.savePreview(artifactId, preview); } return Promise.resolve({ content: [] } satisfies CallToolResult); } // "The artifact is on screen." Answered here for the same reason the // preview is: it is this host's own call, not a server tool, and it must // never reach `/executions`. The page uses it to take its skeleton down // — see `ArtifactStage`. if (params.name === ARTIFACT_RENDERED_TOOL) { onRenderedRef.current?.(); return Promise.resolve({ content: [] } satisfies CallToolResult); } return hostRef.current.callServerTool({ name: params.name, ...(params.arguments ? { arguments: params.arguments } : {}), }); }; bridge.onopenlink = async (params) => { await hostRef.current.openLink({ url: params.url }); return {}; }; void bridge .connect(new PostMessageTransport(contentWindow, contentWindow)) .catch((error: unknown) => { console.error("[executor-console] Artifact shell failed to connect:", error); }); bridgeRef.current = bridge; // Detach: close THIS bridge, and only clear the shared ref if it is still // the current one, so a bridge that has already been replaced can never // null out its successor. return () => { void bridge.close(); if (bridgeRef.current === bridge) bridgeRef.current = null; if (frameRef.current === frame) frameRef.current = null; }; }, []); return ( // The measured box. `h-full` against the page's content area is what makes // the measurement meaningful: the container is exactly the room the page has // given the artifact, so the number the observer reads is the number the // frame should be. `min-h-0` lets it shrink inside the page's flex column // rather than being floored at its content's height.