1098 lines · 43.7 KB · TypeScript
RawDownload
// `virtual:executor-inner-renderer` is supplied by `innerRendererPlugin`// (`@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 the shell;// a consumer's tsconfig `include` covers only its own sources./// <reference path="./inner-renderer-source.d.ts" />import "./globals.css";import "@tailwindcss/browser"; import React, { useState, useEffect, useRef, useCallback, type ReactNode } from "react";import type { McpUiHostContext } from "@modelcontextprotocol/ext-apps";import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";import {  elicitationDefaultContent,  useElicitationApproval,} from "@executor-js/react/components/elicitation-approval"; import {  createToolCaller,  type ToolCallHost,  type TrustedInteraction,  type TrustedInteractionResponse,} from "./proxy";import * as Components from "./components";import { PREVIEW_CAPTURE_HOST_CONTEXT_KEY, SAVE_ARTIFACT_PREVIEW_TOOL } from "./preview-capture";import { ARTIFACT_RENDERED_TOOL, RENDER_SIGNAL_HOST_CONTEXT_KEY } from "./render-signal";import { isFillDisplayMode } from "./display-mode";import innerRendererScript from "virtual:executor-inner-renderer";// Raw source, not a compiled stylesheet: the inner frame compiles utilities on// demand against these tokens. See `buildRendererSrcDoc`.import themeSource from "./theme.css?raw";// The in-frame Tailwind compiler, as text to inline under a CSP that forbids// fetching anything. Supplied by `tailwindBrowserSourcePlugin` — the package// exports only its module entry, and this needs the bytes.import tailwindBrowserScript from "virtual:executor-tailwind-browser"; type PendingInteraction = TrustedInteraction & {  resolve: (response: TrustedInteractionResponse) => void;  /** Where a "don't ask again" for this pause would be stored, or `null` when   *  the pause cannot be scoped to a tool and artifact — see   *  `approvalGrantKey`. `null` hides the option rather than storing a grant   *  that would match the wrong thing. */  grantKey: string | null;}; export type McpAppsShellHost = ToolCallHost & {  readonly getHostContext: () => McpUiHostContext | undefined;  readonly openLink: (params: { url: string }) => Promise<unknown>;  ontoolinput?: (params: { arguments?: Record<string, unknown> }) => void;  ontoolresult?: (result: CallToolResult) => void;  onerror?: (err: Error) => void;  onhostcontextchanged?: (ctx: McpUiHostContext) => void;}; type RendererState = {  token: string;  code: string;  srcDoc: string;  config: Record<string, unknown>;  height: number;}; type RendererRequest =  | {      type: "executor.toolCall";      requestId: number;      token: string;      path: unknown;      args: unknown;      role?: unknown;    }  | { type: "executor.renderer.ready"; token: string }  | { type: "executor.renderer.config"; token: string; config: unknown }  | { type: "executor.renderer.size"; token: string; height: unknown }  | { type: "executor.renderer.error"; token: string; message: unknown }  | { type: "executor.renderer.preview"; token: string; preview: unknown }  /** The inner frame has committed and painted a tree — see `RenderSignal` in   *  `./inner-renderer`. Relayed to the host as `ARTIFACT_RENDERED_TOOL`. */  | { type: "executor.renderer.rendered"; token: string }; /** * Whether this host wants a preview snapshot captured. * * Advertised by the console through `McpUiHostContext`'s open index signature, * which is the spec's own forward-compatibility seam. Absent everywhere else — * a real MCP client has nowhere to put a preview — and absent means the inner * frame never captures anything, so the cost is not paid where it cannot pay * off. */const hostContextAllowsPreviewCapture = (ctx: McpUiHostContext | undefined): boolean =>  ctx?.[PREVIEW_CAPTURE_HOST_CONTEXT_KEY] === true; /** * Whether this host wants to be told when the artifact is on screen. * * Only a host that holds its own loading surface across the open does — the * console — and it says so on the same open index signature the preview * capability travels on. Absent everywhere else, and absent means the call is * never made, so no MCP client is sent a tool name it has never heard of. */const hostContextWantsRenderSignal = (ctx: McpUiHostContext | undefined): boolean =>  ctx?.[RENDER_SIGNAL_HOST_CONTEXT_KEY] === true; // ---------------------------------------------------------------------------// Theme application from MCP Apps host context// --------------------------------------------------------------------------- function applyTheme(ctx: McpUiHostContext) {  if (ctx.theme) {    document.documentElement.classList.toggle("dark", ctx.theme === "dark");  }} /** * The CSS hook for a bounded viewport. See the `html.artifact-fill` rule in * `theme.css` for what it does and why it cannot be unconditional. * * Applied to the shell's own document here, and to the inner frame's document by * the inner renderer when the shell tells it the mode — the chain has to hold at * every link or the artifact's `h-full` still resolves against `auto`. */const FILL_DOCUMENT_CLASS = "artifact-fill"; const applyFillMode = (ctx: McpUiHostContext | undefined) => {  document.documentElement.classList.toggle(FILL_DOCUMENT_CLASS, isFillDisplayMode(ctx));}; /** * How tall the shell may grow before it scrolls internally, or `undefined` for * "as tall as the content is". * * Precedence, tightest first: * *  1. The generated component's own `config.maxHeight` — the author asked for a *     scroll box, so give them one. *  2. The host's `containerDimensions`, the MCP-Apps way of saying "this is the *     room you have". *  3. Otherwise NO cap. An app cannot know how much room its host has; the *     protocol's answer is for the app to report its true content height via *     `size-changed` and let the host size the frame. Capping here would also be *     circular for a host that does exactly that — the shell's own viewport IS *     the frame the host is trying to size, so measuring it would pin the frame *     to whatever height it already had. * * The old unconditional 800px default was case 3 done wrong: it silently clipped * anything taller, in every host, including ones with room to spare. * * ## This function is not consulted in fill mode * * A max height is a ceiling on GROWTH, which is a concept that only means * something when the frame grows — that is, inline. In fill mode the shell is * already exactly the host's viewport, so there is nothing left to cap: the * layout is `height: 100%` all the way down and the artifact scrolls inside * itself. * * That includes `config.maxHeight`. A component asking for a 600px scroll box is * describing how it wants to sit inside a taller document; on a page where it IS * the document, honoring it would letterbox the artifact into the top 600px of * an otherwise empty screen. So in fill mode it is deliberately ignored, and it * keeps its exact inline meaning everywhere else. */const resolveMaxHeight = (  config: Record<string, unknown>,  hostContext: McpUiHostContext | undefined,): number | undefined => {  if (isFillDisplayMode(hostContext)) return undefined;   if (typeof config.maxHeight === "number") return config.maxHeight;   const container = hostContext?.containerDimensions;  if (container) {    if ("height" in container && typeof container.height === "number") return container.height;    if ("maxHeight" in container && typeof container.maxHeight === "number") {      return container.maxHeight;    }  }   return undefined;}; const createRendererToken = (): string => {  if (typeof crypto !== "undefined" && "randomUUID" in crypto) {    return crypto.randomUUID();  }  return `renderer_${Date.now()}_${Math.random().toString(36).slice(2)}`;}; const escapeInlineHtml = (value: string): string =>  value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;"); const escapeStyleContent = (value: string): string => value.replace(/<\/style/gi, "<\\/style"); const escapeScriptContent = (value: string): string => value.replace(/<\/script/gi, "<\\/script"); const collectShellCss = (): string =>  Array.from(document.styleSheets)    .map((sheet) => {      try {        return Array.from(sheet.cssRules)          .map((rule) => rule.cssText)          .join("\n");      } catch {        return "";      }    })    .filter((css) => css.length > 0)    .join("\n"); /** * The srcDoc the model's component renders into. * * It carries TWO stylesheets, and the difference between them is the whole * reason artifacts used to look half-styled: * *  - `collectShellCss()` is the shell's own COMPILED stylesheet, scraped out of *    the live document. It has the `@font-face` rules with their `data:` URLs *    already resolved (the build did that), plus every utility executor's own *    components happen to use. It cannot contain anything else, because *    Tailwind only emits utilities it saw in a source file at build time — and *    the model's code did not exist then. * *  - `themeSource` + the in-frame compiler is what makes the model's OWN *    classes real. `text-2xl`, `md:grid-cols-4`, `tracking-[0.08em]` — none of *    those appear in executor's components, so none of them were in the *    compiled sheet, and they silently did nothing: `text-2xl` computed to *    16px, a responsive grid stayed one column. The frame therefore gets the *    theme as SOURCE, in the `text/tailwindcss` style tag `@tailwindcss/browser` *    looks for, and compiles utilities on demand against executor's tokens. * * The compiler is inlined rather than fetched because the CSP here is * `default-src 'none'` with no `connect-src` — deliberately, since artifact code * is untrusted. Nothing in this frame may open a connection, so everything it * needs travels with it. */const buildRendererSrcDoc = (token: string): string => {  const css = collectShellCss();  return `<!doctype html><html>  <head>    <meta charset="utf-8">    <meta name="executor-render-token" content="${escapeInlineHtml(token)}">    <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline' 'unsafe-eval'; style-src 'unsafe-inline'; img-src data: blob:; font-src data:; connect-src 'none'; base-uri 'none'; form-action 'none'; frame-src 'none'; worker-src 'none'">    <style>${escapeStyleContent(css)}</style>    <style type="text/tailwindcss">@import "tailwindcss";${escapeStyleContent(themeSource)}</style>    <script>${escapeScriptContent(tailwindBrowserScript)}</script>  </head>  <body>    <div id="root"></div>    <script>${escapeScriptContent(innerRendererScript)}</script>  </body></html>`;}; const isRecord = (value: unknown): value is Record<string, unknown> =>  typeof value === "object" && value !== null && !Array.isArray(value); // ---------------------------------------------------------------------------// Remembered approvals ("Approve and don't ask again")// --------------------------------------------------------------------------- /** * A grant is scoped to ONE tool address, on ONE artifact, for ONE viewer. * *  - Tool address: `interaction.address`, the only stable identity a pause *    carries (the execution id is minted per call). Approving *    `github.issues.create` here says nothing about `github.repos.delete`. *  - Artifact: an artifact's bindings are what turn that address into a real *    connection, so the same address on a different artifact is a different *    action and asks again. *  - Viewer: v1 scoping is the storage itself. The shell frame is same-origin *    with the console (see the outer sandbox in `artifact-renderer.tsx`), so *    these grants live in the console origin's `localStorage`, which is already *    per browser profile and therefore per signed-in person at this machine. *    There is no viewer id inside the shell frame and inventing one would be a *    fiction; when grants need to follow a viewer across devices they belong on *    the server, keyed by the real viewer id. * * Nothing here is a security boundary. The SERVER still gates every call; this * only decides whether the shell shows the human a modal it would otherwise * answer the same way. */const APPROVAL_GRANTS_STORAGE_KEY = "executor.shell.approval-grants.v1"; /** * `localStorage`, or `null` when it is unusable. * * The shell document is same-origin with the console and normally has storage, * but that is a property of how it is embedded, not a guarantee: an opaque * origin throws on the property read itself, and a browser with site data * blocked throws on write while still exposing the object. Both are probed * here, once per call, so an approval can never be broken by storage — the * worst case is that a grant is not remembered. */const approvalGrantStorage = (): Storage | null => {  try {    const storage = globalThis.localStorage;    const probe = `${APPROVAL_GRANTS_STORAGE_KEY}.probe`;    storage.setItem(probe, "1");    storage.removeItem(probe);    return storage;  } catch {    return null;  }}; const readApprovalGrants = (): ReadonlySet<string> => {  const storage = approvalGrantStorage();  if (!storage) return new Set();  try {    const raw = storage.getItem(APPROVAL_GRANTS_STORAGE_KEY);    if (!raw) return new Set();    const parsed: unknown = JSON.parse(raw);    return new Set(      Array.isArray(parsed)        ? parsed.filter((entry): entry is string => typeof entry === "string")        : [],    );  } catch {    return new Set();  }}; const rememberApprovalGrant = (key: string): void => {  const storage = approvalGrantStorage();  if (!storage) return;  const next = new Set(readApprovalGrants());  next.add(key);  try {    storage.setItem(APPROVAL_GRANTS_STORAGE_KEY, JSON.stringify([...next]));  } catch {    // Quota, or storage revoked between the probe and the write. The approval    // the user just gave still stands; only the memory of it is lost.  }}; /** * `null` when this pause cannot be scoped — no artifact id, or a pause with no * address on it. Such an interaction is never auto-approved and never offers to * be remembered, because a grant we cannot scope is a grant we cannot honor * precisely. */const approvalGrantKey = (artifactId: string | undefined, address: unknown): string | null =>  artifactId !== undefined &&  artifactId.length > 0 &&  typeof address === "string" &&  address.length > 0    ? // A space separates the two halves: artifact ids are `art_`-prefixed      // base36 and tool addresses are dot-joined identifiers, so neither can      // contain one and no pair can collide.      `${artifactId} ${address}`    : null; // ---------------------------------------------------------------------------// Shell App — connects to MCP host, receives code, renders components// --------------------------------------------------------------------------- export function McpAppsShell({  app,  initialCode,}: {  app: McpAppsShellHost;  initialCode?: string | undefined;}) {  const [component, setComponent] = useState<React.ComponentType | null>(null);  const [renderer, setRenderer] = useState<RendererState | null>(null);  const [error, setError] = useState<string | null>(null);  const [hostContext, setHostContext] = useState<McpUiHostContext | undefined>();  const [pendingInteraction, setPendingInteraction] = useState<PendingInteraction | null>(null);  const callToolRef = useRef<    (path: readonly string[], args: readonly unknown[], role?: string) => Promise<unknown>  >(() => Promise.resolve(null));  // Which artifact is rendering. Set from the tool input the host delivers  // alongside the code, so the server can look up this artifact's connection  // bindings when the component calls a tool. Held in a ref rather than state  // because only the tool caller reads it, and rebuilding the caller on every  // delivery would race the first call.  const artifactIdRef = useRef<string | undefined>(undefined);  const pendingInteractionRef = useRef<PendingInteraction | null>(null);  const rendererFrameRef = useRef<HTMLIFrameElement | null>(null);  const rendererRef = useRef<RendererState | null>(null);  // Whether the embedding host can store a preview snapshot. Held in a ref  // because the renderer message handler is built once and must not be rebuilt  // when the context changes.  const capturePreviewRef = useRef(false);  capturePreviewRef.current = hostContextAllowsPreviewCapture(hostContext);  // Whether the host has bounded this shell. Held in a ref for the same reason  // as the capture flag: the renderer message handler is built once, and reading  // this from the closure would pin whichever mode was current when it was made.  const fillRef = useRef(false);  fillRef.current = isFillDisplayMode(hostContext);  // Hand a captured snapshot to the host. Swallows every failure: a preview is  // a picture for a list, and the artifact the user is looking at does not  // depend on it in any way.  const savePreviewRef = useRef<(artifactId: string, preview: string) => void>(() => {});  savePreviewRef.current = (artifactId, preview) => {    void Promise.resolve(      app.callServerTool({        name: SAVE_ARTIFACT_PREVIEW_TOOL,        arguments: { artifactId, preview },      }),    ).then(      () => undefined,      () => undefined,    );  };  // Whether the host asked to be told when the artifact paints. A ref for the  // same reason as the two above: the renderer message handler is built once,  // and reading this from the closure would pin whichever context was current  // when it was made.  const wantsRenderSignalRef = useRef(false);  wantsRenderSignalRef.current = hostContextWantsRenderSignal(hostContext);  /**   * Tell the host the artifact is on screen. At most once per shell.   *   * Once-only because the host's question is "may I take my loading surface   * down", which is answered exactly once; a re-render inside the artifact is   * not a second answer. The latch lives here rather than in the inner frame so   * it survives that frame remounting.   *   * ## The fact outlives the capability to report it   *   * "The artifact painted" and "the host wants to know" arrive over two   * INDEPENDENT channels, and their order is not guaranteed: the paint comes up   * from the inner frame, while the capability comes down on the host context —   * which on a fast host lands before the first render and on a slow one arrives   * after it. Dropping the signal because the context had not arrived yet leaves   * the host holding its loading surface over a fully rendered artifact,   * forever, since the paint never happens twice.   *   * So the paint is REMEMBERED rather than tested against the capability at the   * moment it occurs. Whichever of the two arrives second sends the call.   *   * Swallows every failure, like the preview relay: a host that does not   * implement the name rejects the call, and the correct response to that is   * nothing at all.   */  const renderSignalSentRef = useRef(false);  /** The artifact has painted, whether or not anyone could be told yet. */  const renderedRef = useRef(false);  const signalRenderedRef = useRef<() => void>(() => {});  signalRenderedRef.current = () => {    renderedRef.current = true;    if (renderSignalSentRef.current || !wantsRenderSignalRef.current) return;    renderSignalSentRef.current = true;    void Promise.resolve(      app.callServerTool({        name: ARTIFACT_RENDERED_TOOL,        ...(artifactIdRef.current === undefined          ? {}          : { arguments: { artifactId: artifactIdRef.current } }),      }),    ).then(      () => undefined,      () => undefined,    );  };   // The other order: the artifact painted before the host said it wanted to  // know. Runs on every host-context change, and the latch inside makes all but  // the first a no-op.  useEffect(() => {    if (renderedRef.current) signalRenderedRef.current();  }, [hostContext]);   useEffect(() => {    rendererRef.current = renderer;  }, [renderer]);   const requestTrustedInteraction = useCallback(    (interaction: TrustedInteraction): Promise<TrustedInteractionResponse> =>      new Promise((resolve) => {        // Remembered approvals are answered HERE, before any state is set, so a        // granted tool never mounts the modal — not even for a frame.        const grantKey = approvalGrantKey(artifactIdRef.current, interaction.interaction.address);        if (grantKey !== null && readApprovalGrants().has(grantKey)) {          // A schema-bearing elicitation is only auto-answerable when its own          // defaults satisfy it. A required field with no default has no          // answer the user ever gave, so fall through to the modal rather          // than resuming with fabricated values.          const content = elicitationDefaultContent(interaction.interaction.requestedSchema);          if (content !== null) {            resolve({ action: "accept", content });            return;          }        }         if (pendingInteractionRef.current) {          resolve({ action: "cancel" });          return;        }         const pending = { ...interaction, resolve, grantKey };        pendingInteractionRef.current = pending;        setPendingInteraction(pending);      }),    [],  );   const completeTrustedInteraction = useCallback((response: TrustedInteractionResponse) => {    const pending = pendingInteractionRef.current;

Lines 1–500 of 1098.