432 lines · 21.4 KB · TypeScript
RawDownload
// `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./// <reference path="./shell-html-url.d.ts" />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<string, unknown>;  }) => Promise<CallToolResult>;  readonly getHostContext: () => { readonly theme: "light" | "dark" } | undefined;  readonly openLink: (params: { url: string }) => Promise<unknown>;  /** 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<HTMLIFrameElement | null>(null);  const containerRef = useRef<HTMLDivElement | null>(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<number | null>(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<AppBridge | null>(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.    <div      ref={containerRef}      data-testid="artifact-shell-container"      className="h-full min-h-0 w-full"    >      <iframe        // Keyed on the source: opening a different artifact remounts the        // element, which detaches the old bridge and attaches a fresh one to a        // fresh document rather than reusing a shell that has already rendered        // something else.        key={props.code}        ref={attachFrame}        data-testid="artifact-shell-frame"        title="Artifact"        src={shellHtmlUrl}        // The shell document is our own build, but it runs model-written JSX in        // a further nested frame of its own. Scripts and same-origin are what it        // needs to boot React and reach that inner frame; popups let `openLink`        // escape the sandbox rather than silently failing. Deliberately absent:        // `allow-top-navigation` (nothing in the shell navigates the console)        // and `allow-forms` (nothing posts one).        sandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox"        // Fill mode: the frame is exactly the measured viewport, and stays there        // however tall the artifact's content becomes. Inline fallback: the        // frame is the app's reported content height and the page scrolls.        style={{ height: viewport ?? contentHeight }}        className="block w-full border-0 bg-background"      />    </div>  );}