// `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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """); 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. Load more
// `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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """); 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;