From 1e50ff327e2f0fd708a7620484789b9cd9dc3cbc Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:36:15 -0700 Subject: [PATCH] Serve the MCP-Apps shell from static assets on Workers hosts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deployed cloud Worker has no filesystem, so loadMcpAppsShellHtml's fs.readFile always failed and the ui://executor/shell.html resource silently served a 'Shell not built' placeholder — an inert document that never starts the MCP-Apps handshake, so every artifact widget in every MCP client spun on its loading state forever with no error anywhere. - Workers hosts (cloud, host-cloudflare) now fetch the built shell through the ASSETS binding: mcpAppsShellAsset emits a stable-named copy alongside the content-hashed one, and makeAssetsShellHtmlLoader (new @executor-js/mcp-apps-shell/worker entry) reads and verifies it. Under vite dev the document travels inline via a dev-only virtual module, since no assets are emitted yet. - The shell document carries an identity marker the loaders verify, so an SPA not-found fallback (index.html with a 200) can never be served as the shell. - The self-host image reads the same emitted asset from its SPA dist; the runtime image has no packages/ tree for the old candidates. - The silent placeholder is gone: a host that cannot produce the shell fails the resource read with an actionable error. - App builds now fail when the shell asset is missing or wrong (apps/cloud scripts/build.mjs, assert-shell-asset.mjs on both hosts). --- .changeset/violet-rules-hang.md | 7 ++ apps/cli/src/build.ts | 8 +- apps/cloud/scripts/build.mjs | 21 ++++- apps/cloud/src/mcp/session-durable-object.ts | 16 +++- apps/host-cloudflare/package.json | 4 +- .../scripts/assert-shell-asset.mjs | 17 ++++ apps/host-cloudflare/src/config.ts | 6 +- .../src/mcp/session-durable-object.ts | 13 ++- apps/host-cloudflare/wrangler.jsonc | 4 + apps/host-selfhost/package.json | 2 +- .../scripts/assert-shell-asset.mjs | 18 ++++ packages/hosts/mcp-apps-shell/package.json | 4 + packages/hosts/mcp-apps-shell/src/index.ts | 10 ++- .../mcp-apps-shell/src/shell-asset-path.ts | 35 ++++++++ .../mcp-apps-shell/src/shell-dev-html.d.ts | 14 +++ .../hosts/mcp-apps-shell/src/shell-html.ts | 82 +++++++++++------- .../src/shell-resource.smoke.test.ts | 21 +++-- .../mcp-apps-shell/src/shell/mcp-app.html | 5 ++ .../hosts/mcp-apps-shell/src/vite.test.ts | 11 +++ packages/hosts/mcp-apps-shell/src/vite.ts | 50 ++++++++++- .../hosts/mcp-apps-shell/src/worker.test.ts | 85 +++++++++++++++++++ packages/hosts/mcp-apps-shell/src/worker.ts | 75 ++++++++++++++++ packages/hosts/mcp-apps-shell/tsconfig.json | 1 + 23 files changed, 451 insertions(+), 58 deletions(-) create mode 100644 .changeset/violet-rules-hang.md create mode 100644 apps/host-cloudflare/scripts/assert-shell-asset.mjs create mode 100644 apps/host-selfhost/scripts/assert-shell-asset.mjs create mode 100644 packages/hosts/mcp-apps-shell/src/shell-asset-path.ts create mode 100644 packages/hosts/mcp-apps-shell/src/shell-dev-html.d.ts create mode 100644 packages/hosts/mcp-apps-shell/src/worker.test.ts create mode 100644 packages/hosts/mcp-apps-shell/src/worker.ts diff --git a/.changeset/violet-rules-hang.md b/.changeset/violet-rules-hang.md new file mode 100644 index 000000000..d0f52e758 --- /dev/null +++ b/.changeset/violet-rules-hang.md @@ -0,0 +1,7 @@ +--- +"executor": patch +--- + +**Fix: MCP clients of the cloud host got a "Shell not built" placeholder as the `ui://executor/shell.html` resource, so every artifact rendered as a widget that never finished loading** + +The deployed Worker has no filesystem, and the shell loader silently fell back to an inert placeholder document when its `fs.readFile` failed. Workers hosts now fetch the built shell through the static-assets binding (the app build emits a stable-named copy alongside the hashed one), the self-host image reads the same emitted asset from its SPA dist, and a host that cannot produce the shell now fails the resource read with an actionable error instead of serving a document that hangs the client. App builds fail if the shell asset was not emitted. diff --git a/apps/cli/src/build.ts b/apps/cli/src/build.ts index 1fc7ad937..eef1b6958 100644 --- a/apps/cli/src/build.ts +++ b/apps/cli/src/build.ts @@ -31,10 +31,10 @@ const resolveQuickJsWasmPath = (): string => { // The MCP host serves the shell as the `ui://executor/shell.html` resource, and // `@executor-js/mcp-apps-shell` reads it from disk at runtime. That runtime // `fs.readFile` is invisible to `bun build --compile`, so without this the -// packaged binary would serve the "Shell not built" placeholder and every -// generated UI would render blank. Build it if missing and copy it next to the -// executable, where the loader finds it via `process.execPath` — the same -// colocation trick used for `libsql.node` / `emscripten-module.wasm`. +// packaged binary would fail every artifact resource read. Build it if missing +// and copy it next to the executable, where the loader finds it via +// `process.execPath` — the same colocation trick used for `libsql.node` / +// `emscripten-module.wasm`. // --------------------------------------------------------------------------- const mcpAppsShellDir = resolve(repoRoot, "packages/hosts/mcp-apps-shell"); diff --git a/apps/cloud/scripts/build.mjs b/apps/cloud/scripts/build.mjs index aeae418e3..cc02077be 100644 --- a/apps/cloud/scripts/build.mjs +++ b/apps/cloud/scripts/build.mjs @@ -9,7 +9,7 @@ import { spawnSync } from "node:child_process"; import { randomBytes } from "node:crypto"; -import { rmSync } from "node:fs"; +import { existsSync, readFileSync, rmSync } from "node:fs"; if (!process.env.VITE_PUBLIC_ANALYTICS_PATH) { process.env.VITE_PUBLIC_ANALYTICS_PATH = randomBytes(4).toString("hex"); @@ -32,3 +32,22 @@ for (const step of steps) { process.exit(result.status ?? 1); } } + +// The deployed Worker serves the MCP-Apps shell (`ui://executor/shell.html`) +// by fetching this stable-named asset through the ASSETS binding — it has no +// filesystem to read the shell from. A build that failed to emit it would +// deploy a Worker whose artifact resource reads all fail, so make the miss a +// build failure here instead of a production incident. Same shape as the +// worker-bundler asset assertion from #1378. +const shellAsset = new URL( + "../dist/client/assets/executor-mcp-apps-shell-stable.html", + import.meta.url, +); +if (!existsSync(shellAsset)) { + console.error(`[build] missing MCP-Apps shell asset at ${shellAsset.pathname}`); + process.exit(1); +} +if (!readFileSync(shellAsset, "utf8").includes('name="executor-mcp-apps-shell"')) { + console.error(`[build] ${shellAsset.pathname} is not the MCP-Apps shell document`); + process.exit(1); +} diff --git a/apps/cloud/src/mcp/session-durable-object.ts b/apps/cloud/src/mcp/session-durable-object.ts index 075e6ad37..021cb34a9 100644 --- a/apps/cloud/src/mcp/session-durable-object.ts +++ b/apps/cloud/src/mcp/session-durable-object.ts @@ -26,7 +26,7 @@ import { } from "@executor-js/host-mcp/tool-server"; import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval"; import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact"; -import { loadMcpAppsShellHtml } from "@executor-js/mcp-apps-shell"; +import { makeAssetsShellHtmlLoader } from "@executor-js/mcp-apps-shell/worker"; import { smokeRenderArtifact } from "@executor-js/mcp-apps-shell/smoke-render"; import { McpAgentSessionDOBase, @@ -156,6 +156,18 @@ const makeSessionServices = (dbHandle: CloudSessionDbHandle) => { return Layer.mergeAll(DbLive, UserStoreLive, CoreSharedServices); }; +// The `ui://executor/shell.html` resource, over the ASSETS binding: the +// deployed Worker has no filesystem, so the document is the stable-named +// asset the client build emitted (`mcpAppsShellAsset`), fetched at first +// artifact resource read. Module scope so the fetch-and-verify happens once +// per isolate, not once per session. The dev thunk carries the built shell +// inline under `vite dev`, where no assets exist yet for the binding to find. +const loadAppShellHtml = makeAssetsShellHtmlLoader({ + assets: env.ASSETS, + devShellHtml: () => + import("virtual:executor-mcp-apps-shell-dev-html").then((mod) => mod.devShellHtml), +}); + // --------------------------------------------------------------------------- // Durable Object // --------------------------------------------------------------------------- @@ -268,7 +280,7 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase Promise }; /** MCP session Durable Object namespace — one addressable isolate per MCP * session (the DO id IS the session id), so a session survives across the * Worker's stateless isolates. */ @@ -74,7 +78,7 @@ export interface CloudflareConfig { type CloudflareConfigEnv = Omit< CloudflareEnv, - "DB" | "BLOBS" | "MCP_SESSION" | "MCP_EXECUTION_OWNER" + "DB" | "BLOBS" | "ASSETS" | "MCP_SESSION" | "MCP_EXECUTION_OWNER" >; type CloudflareAccessEnv = Pick< diff --git a/apps/host-cloudflare/src/mcp/session-durable-object.ts b/apps/host-cloudflare/src/mcp/session-durable-object.ts index f211f5216..d2dc9c2d0 100644 --- a/apps/host-cloudflare/src/mcp/session-durable-object.ts +++ b/apps/host-cloudflare/src/mcp/session-durable-object.ts @@ -6,7 +6,7 @@ import { } from "@executor-js/host-mcp/tool-server"; import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval"; import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact"; -import { loadMcpAppsShellHtml } from "@executor-js/mcp-apps-shell"; +import { makeAssetsShellHtmlLoader } from "@executor-js/mcp-apps-shell/worker"; import { smokeRenderArtifact } from "@executor-js/mcp-apps-shell/smoke-render"; import type { ExecutorDbHandle } from "@executor-js/api/server"; import { @@ -57,6 +57,14 @@ class McpModelResumeForwardError extends Data.TaggedError("McpModelResumeForward export class McpSessionDO extends McpAgentSessionDOBase { private readonly cfEnv: CloudflareEnv; private readonly cfConfig: CloudflareConfig; + /** + * The `ui://executor/shell.html` document, over the ASSETS binding: a + * deployed Worker has no filesystem, so the shell is the stable-named asset + * the SPA build emitted into `./dist` (`mcpAppsShellAsset`). No dev-mode + * escape hatch here, unlike cloud: this Worker is bundled by wrangler, not + * Vite, and `wrangler dev` serves the same built `./dist` the binding reads. + */ + private readonly loadAppShellHtml: () => Promise; constructor( ctx: ConstructorParameters>[0], @@ -65,6 +73,7 @@ export class McpSessionDO extends McpAgentSessionDOBase` in + * `src/shell/mcp-app.html`), and no other document does. + * + * Exists because a fetch through a Workers ASSETS binding cannot be trusted + * by status alone: with `not_found_handling: "single-page-application"` + * (host-cloudflare), a miss returns the console's `index.html` with a 200. + * Serving THAT as the shell resource would be the exact silent-hang failure + * this seam exists to kill — the client loads a real document that never + * speaks the MCP-Apps protocol. Loaders verify the marker and fail loudly. + */ +export const MCP_APPS_SHELL_DOCUMENT_MARKER = 'name="executor-mcp-apps-shell"'; diff --git a/packages/hosts/mcp-apps-shell/src/shell-dev-html.d.ts b/packages/hosts/mcp-apps-shell/src/shell-dev-html.d.ts new file mode 100644 index 000000000..26927a6b9 --- /dev/null +++ b/packages/hosts/mcp-apps-shell/src/shell-dev-html.d.ts @@ -0,0 +1,14 @@ +/** + * The built shell document's BYTES under `vite dev`, or `undefined` in a + * build — supplied by `mcpAppsShellAsset` (`@executor-js/mcp-apps-shell/vite`). + * + * Only Workers hosts import this, as the `devShellHtml` thunk for + * `makeAssetsShellHtmlLoader`: in dev no assets have been emitted for the + * ASSETS binding to find, so the document travels inline; in a deployed + * build this module is `undefined` and the binding is the real path. + */ +declare module "virtual:executor-mcp-apps-shell-dev-html" { + const devShellHtml: string | undefined; + export { devShellHtml }; + export default devShellHtml; +} diff --git a/packages/hosts/mcp-apps-shell/src/shell-html.ts b/packages/hosts/mcp-apps-shell/src/shell-html.ts index 04c21f4a0..5702de2fd 100644 --- a/packages/hosts/mcp-apps-shell/src/shell-html.ts +++ b/packages/hosts/mcp-apps-shell/src/shell-html.ts @@ -1,49 +1,67 @@ +import { MCP_APPS_SHELL_STABLE_ASSET_PATH } from "./shell-asset-path"; + let shellHtmlCache: string | undefined; /** - * The self-contained shell HTML served as the MCP-Apps `ui://` resource. + * The self-contained shell HTML served as the MCP-Apps `ui://` resource, read + * from the local filesystem. * * Hosts inject this through `SharedMcpServerConfig.loadAppShellHtml` rather * than the MCP host package importing it directly: the shell drags React, * Recharts and Tailwind into whatever graph imports it, and the MCP host also * runs on Workers where none of that belongs. + * + * This loader is for hosts WITH a filesystem: the local CLI/desktop binary + * (shell copied next to the executable by `apps/cli/src/build.ts`), dev runs + * from the repo, and the self-host image (the stable-named copy its SPA build + * emits into `dist/assets/`). Workers hosts use `makeAssetsShellHtmlLoader` + * from `@executor-js/mcp-apps-shell/worker` instead — `fs.readFile` can never + * succeed there. + * + * A missing shell REJECTS, deliberately. This used to degrade to a "Shell not + * built" placeholder, which is the worst possible failure mode: the resource + * read succeeds, the client loads an inert document that never starts the + * MCP-Apps handshake, and every artifact spins on its loading state forever + * with no error anywhere in the chain — which is exactly how production + * served the placeholder unnoticed. A rejected read surfaces as a JSON-RPC + * error naming the missing file and the command that produces it. */ export const loadMcpAppsShellHtml = async (): Promise => { if (shellHtmlCache) return shellHtmlCache; - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: optional prebuilt shell asset is loaded from local filesystem when present - try { - const fs = await import("node:fs/promises"); - const path = await import("node:path"); - const candidates = [ - // `bun build --compile` can't bundle this runtime `fs.readFile`, so the - // binary build (apps/cli/src/build.ts) copies `mcp-app.html` next to the - // executable. We find it via `process.execPath`, the same colocation - // trick native-bindings.ts uses for `libsql.node` / `keyring.node`. - path.join(path.dirname(process.execPath), "mcp-app.html"), - // Dev / package-resolved (`bun run`, vitest): the package's own dist. - path.join(import.meta.dirname, "../dist/mcp-app.html"), - path.join(import.meta.dirname, "../../dist/mcp-app.html"), - ]; + const fs = await import("node:fs/promises"); + const path = await import("node:path"); + const candidates = [ + // `bun build --compile` can't bundle this runtime `fs.readFile`, so the + // binary build (apps/cli/src/build.ts) copies `mcp-app.html` next to the + // executable. We find it via `process.execPath`, the same colocation + // trick native-bindings.ts uses for `libsql.node` / `keyring.node`. + path.join(path.dirname(process.execPath), "mcp-app.html"), + // Dev / package-resolved (`bun run`, vitest): the package's own dist. + path.join(import.meta.dirname, "../dist/mcp-app.html"), + path.join(import.meta.dirname, "../../dist/mcp-app.html"), + // The self-host runtime image. Its server is bundled to + // `apps/host-selfhost/dist-server/serve.js`, and the sibling `dist/` is + // the SPA build, whose `mcpAppsShellAsset` emits the stable-named copy of + // the shell into `dist/assets/`. The package-dist candidates above can't + // hit there — the image carries no `packages/` tree at all. + path.join(import.meta.dirname, "../dist", MCP_APPS_SHELL_STABLE_ASSET_PATH), + ]; - for (const candidate of candidates) { - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: try each possible emitted shell path before falling back - try { - shellHtmlCache = await fs.readFile(candidate, "utf-8"); - return shellHtmlCache; - } catch { - // Try the next candidate path. - } + for (const candidate of candidates) { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: try each possible emitted shell path before failing + try { + shellHtmlCache = await fs.readFile(candidate, "utf-8"); + return shellHtmlCache; + } catch { + // Try the next candidate path. } - } catch { - // Fall through to the development fallback below. } - shellHtmlCache = MCP_APPS_SHELL_NOT_BUILT_HTML; - return shellHtmlCache; + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: a host without the built shell must fail the resource read loudly; a placeholder document hangs every MCP-Apps client silently + throw new Error( + "MCP-Apps shell document not found. Tried:\n" + + candidates.map((candidate) => ` ${candidate}`).join("\n") + + "\nBuild it with: bun run --cwd packages/hosts/mcp-apps-shell build:shell", + ); }; - -/** What the loader serves when no built shell is on disk. Exported so tests can - * assert a host is serving the real thing rather than this placeholder. */ -export const MCP_APPS_SHELL_NOT_BUILT_HTML = - "

Shell not built. Run: bun run --cwd packages/hosts/mcp-apps-shell build:shell

"; diff --git a/packages/hosts/mcp-apps-shell/src/shell-resource.smoke.test.ts b/packages/hosts/mcp-apps-shell/src/shell-resource.smoke.test.ts index af4fc99c1..046138968 100644 --- a/packages/hosts/mcp-apps-shell/src/shell-resource.smoke.test.ts +++ b/packages/hosts/mcp-apps-shell/src/shell-resource.smoke.test.ts @@ -8,14 +8,16 @@ import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server"; import { MCP_APPS_SHELL_RESOURCE_URI } from "@executor-js/host-mcp/create-artifact"; import type { ExecutionEngine } from "@executor-js/execution"; -import { loadMcpAppsShellHtml, MCP_APPS_SHELL_NOT_BUILT_HTML } from "./shell-html"; +import { loadMcpAppsShellHtml } from "./shell-html"; +import { MCP_APPS_SHELL_DOCUMENT_MARKER } from "./shell-asset-path"; // The shipped binary serves the shell from a file on disk (`build:shell` output, // copied next to the executable by `apps/cli/src/build.ts`). If that file is -// missing the loader silently degrades to a placeholder and every generated UI -// renders blank — so assert an MCP client reading the resource gets the REAL -// built shell, not the placeholder. This replaces a build-time subprocess probe -// that hard-slept 7 seconds inside every binary build. +// missing the loader rejects the resource read — it used to degrade to a +// placeholder document, which hung every MCP-Apps client silently — so assert +// an MCP client reading the resource gets the REAL built shell. This replaces +// a build-time subprocess probe that hard-slept 7 seconds inside every binary +// build. const stubEngine: ExecutionEngine = { execute: () => Effect.succeed({ result: "ok" }), @@ -34,7 +36,7 @@ const APPS_CAPS = { } as unknown as ClientCapabilities; describe("MCP-Apps shell resource", () => { - it("serves the built shell to an MCP client, not the 'Shell not built' placeholder", async () => { + it("serves the built shell to an MCP client", async () => { const mcpServer = await Effect.runPromise( // Artifacts are opt-in per connection; the shell resource only exists on // a session that asked for them. @@ -58,9 +60,12 @@ describe("MCP-Apps shell resource", () => { // The shell is served as text, never as a blob. expect("text" in content).toBe(true); const html = "text" in content ? content.text : ""; - expect(html).not.toBe(MCP_APPS_SHELL_NOT_BUILT_HTML); - expect(html).not.toContain("Shell not built"); expect(html).toContain('id="root"'); + // The identity marker the Workers loader authenticates the document by + // (an ASSETS-binding miss under SPA not-found handling answers with + // index.html and a 200). Asserted here so the marker leaving the template + // fails this suite too, not just the app builds. + expect(html).toContain(MCP_APPS_SHELL_DOCUMENT_MARKER); // `vite-plugin-singlefile` inlines every asset: a shell that still points at // a sibling bundle would 404 inside the host's sandboxed iframe. // diff --git a/packages/hosts/mcp-apps-shell/src/shell/mcp-app.html b/packages/hosts/mcp-apps-shell/src/shell/mcp-app.html index 7d347f683..30e25767e 100644 --- a/packages/hosts/mcp-apps-shell/src/shell/mcp-app.html +++ b/packages/hosts/mcp-apps-shell/src/shell/mcp-app.html @@ -3,6 +3,11 @@ + + Executor Shell diff --git a/packages/hosts/mcp-apps-shell/src/vite.test.ts b/packages/hosts/mcp-apps-shell/src/vite.test.ts index 3a364db70..6dcbea006 100644 --- a/packages/hosts/mcp-apps-shell/src/vite.test.ts +++ b/packages/hosts/mcp-apps-shell/src/vite.test.ts @@ -14,4 +14,15 @@ describe("mcpAppsShellAsset", () => { "export default shellHtmlUrl;\n", ); }); + + it("resolves the dev-html module to undefined in a build, so Workers use the ASSETS binding", async () => { + const plugin = mcpAppsShellAsset(); + plugin.configResolved({ command: "build" }); + + const resolved = plugin.resolveId("virtual:executor-mcp-apps-shell-dev-html"); + expect(resolved).toBeDefined(); + expect(await plugin.load(resolved ?? "")).toBe( + "export const devShellHtml = undefined;\nexport default devShellHtml;\n", + ); + }); }); diff --git a/packages/hosts/mcp-apps-shell/src/vite.ts b/packages/hosts/mcp-apps-shell/src/vite.ts index 2c8ceb715..a3d313e3d 100644 --- a/packages/hosts/mcp-apps-shell/src/vite.ts +++ b/packages/hosts/mcp-apps-shell/src/vite.ts @@ -25,6 +25,11 @@ import { createHash } from "node:crypto"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { + MCP_APPS_SHELL_DOCUMENT_MARKER, + MCP_APPS_SHELL_STABLE_ASSET_PATH, +} from "./shell-asset-path.ts"; + /** The structural shape of the Vite plugin object, declared locally so this * module imports no Vite types (apps pin their own Vite version). */ export interface InnerRendererVitePlugin { @@ -172,6 +177,12 @@ export const tailwindBrowserSourcePlugin = (): InnerRendererVitePlugin => ({ const SHELL_VIRTUAL_ID = "virtual:executor-mcp-apps-shell-html"; const SHELL_RESOLVED_ID = `\0${SHELL_VIRTUAL_ID}`; const DEV_SHELL_URL = "/assets/executor-mcp-apps-shell-dev.html"; +// The document's BYTES for the Worker in dev, where the stable asset does not +// exist yet (assets are only emitted by a build) and the dev ASSETS binding +// resolves against files on disk. `makeAssetsShellHtmlLoader` imports this +// lazily, so the shell only builds when an artifact resource is actually read. +const DEV_HTML_VIRTUAL_ID = "virtual:executor-mcp-apps-shell-dev-html"; +const DEV_HTML_RESOLVED_ID = `\0${DEV_HTML_VIRTUAL_ID}`; /** Where `build:shell` writes the self-contained shell document. */ export const mcpAppsShellHtmlPath = (): string => path.resolve(packageRoot, "dist/mcp-app.html"); @@ -283,6 +294,17 @@ export const mcpAppsShellAsset = (): McpAppsShellAssetVitePlugin => { const load = async (): Promise<{ readonly html: string; readonly url: string }> => { if (cached) return cached; const html = await buildMcpAppsShellHtml(); + // Build-blocking: runtime loaders authenticate the document by this + // fragment (an ASSETS-binding miss under SPA not-found handling answers + // with index.html and a 200), so a shell that lost it would make every + // deployed resource read fail. Catch that here, in the build. + if (!html.includes(MCP_APPS_SHELL_DOCUMENT_MARKER)) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: Vite plugin hooks report build failures by throwing + throw new Error( + `Built MCP-Apps shell is missing its identity marker (${MCP_APPS_SHELL_DOCUMENT_MARKER}); ` + + "check the in src/shell/mcp-app.html.", + ); + } const digest = createHash("sha256").update(html).digest("hex").slice(0, 12); cached = { html, url: `/assets/executor-mcp-apps-shell-${digest}.html` }; return cached; @@ -300,8 +322,24 @@ export const mcpAppsShellAsset = (): McpAppsShellAssetVitePlugin => { // only carries the single-environment case, where the two agree. ssr = Boolean(config.build?.ssr); }, - resolveId: (id) => (id === SHELL_VIRTUAL_ID ? SHELL_RESOLVED_ID : undefined), + resolveId: (id) => { + if (id === SHELL_VIRTUAL_ID) return SHELL_RESOLVED_ID; + if (id === DEV_HTML_VIRTUAL_ID) return DEV_HTML_RESOLVED_ID; + return undefined; + }, load: async (id) => { + if (id === DEV_HTML_RESOLVED_ID) { + // The Worker's dev-only escape hatch: under `build` it exports + // `undefined` and `makeAssetsShellHtmlLoader` uses the ASSETS binding; + // under `serve` it carries the built document inline, because no + // assets have been emitted for the binding to find. Guarded the same + // way as the URL module below — the build must not run at startup. + if (command !== "serve") { + return "export const devShellHtml = undefined;\nexport default devShellHtml;\n"; + } + const { html } = await load(); + return `export const devShellHtml = ${JSON.stringify(html)};\nexport default devShellHtml;\n`; + } if (id !== SHELL_RESOLVED_ID) return undefined; // Resolving the virtual module is part of Vite's startup graph. Building // the multi-megabyte shell here makes every dev server pay that cost @@ -355,6 +393,16 @@ export const mcpAppsShellAsset = (): McpAppsShellAssetVitePlugin => { // configured — which `process.cwd()`-relative writes get wrong for // TanStack Start, where the client build writes to `dist/client`. this.emitFile({ type: "asset", fileName: url.replace(/^\//, ""), source: html }); + // A second copy at the STABLE name, for the server side: a Workers host + // serves the `ui://executor/shell.html` MCP resource by fetching this + // through its ASSETS binding (`makeAssetsShellHtmlLoader`), and the + // self-host image reads it from the SPA dist on disk — neither can know + // the content hash the client build minted. See `./shell-asset-path`. + this.emitFile({ + type: "asset", + fileName: MCP_APPS_SHELL_STABLE_ASSET_PATH.replace(/^\//, ""), + source: html, + }); }, async closeBundle() { cached = undefined; diff --git a/packages/hosts/mcp-apps-shell/src/worker.test.ts b/packages/hosts/mcp-apps-shell/src/worker.test.ts new file mode 100644 index 000000000..b38618f17 --- /dev/null +++ b/packages/hosts/mcp-apps-shell/src/worker.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { makeAssetsShellHtmlLoader, type ShellAssetsBinding } from "./worker"; +import { + MCP_APPS_SHELL_DOCUMENT_MARKER, + MCP_APPS_SHELL_STABLE_ASSET_PATH, +} from "./shell-asset-path"; + +const SHELL_HTML = `
`; + +const bindingReturning = ( + responses: readonly Response[], +): { readonly binding: ShellAssetsBinding; readonly requests: Request[] } => { + const requests: Request[] = []; + let call = 0; + return { + requests, + binding: { + fetch: (request) => { + requests.push(request); + const response = responses[Math.min(call, responses.length - 1)]; + call += 1; + // Running out of scripted responses means the test was wrong about + // how many fetches the loader makes; a distinctive 599 makes the + // resulting assertion failure say so. + return Promise.resolve( + response ?? new Response("stub exhausted: unexpected fetch", { status: 599 }), + ); + }, + }, + }; +}; + +describe("makeAssetsShellHtmlLoader", () => { + it("serves the shell from the stable asset path and caches it", async () => { + const { binding, requests } = bindingReturning([new Response(SHELL_HTML)]); + const load = makeAssetsShellHtmlLoader({ assets: binding }); + + expect(await load()).toBe(SHELL_HTML); + expect(await load()).toBe(SHELL_HTML); + expect(requests).toHaveLength(1); + expect(new URL(requests[0]?.url ?? "").pathname).toBe(MCP_APPS_SHELL_STABLE_ASSET_PATH); + }); + + it("rejects on a non-OK response instead of serving a substitute document", async () => { + const { binding } = bindingReturning([new Response("not found", { status: 404 })]); + const load = makeAssetsShellHtmlLoader({ assets: binding }); + + await expect(load()).rejects.toThrow(/404/); + await expect(load()).rejects.toThrow(MCP_APPS_SHELL_STABLE_ASSET_PATH); + }); + + it("rejects a 200 that is not the shell (SPA not-found fallback)", async () => { + // Under `not_found_handling: "single-page-application"` a miss answers + // with the console's index.html and a 200 — the exact document that, if + // served as the ui:// resource, hangs every client with no error. + const { binding } = bindingReturning([ + new Response("
console
"), + ]); + const load = makeAssetsShellHtmlLoader({ assets: binding }); + + await expect(load()).rejects.toThrow(/different document/); + }); + + it("prefers the dev document and never touches the binding when it is present", async () => { + const { binding, requests } = bindingReturning([]); + const load = makeAssetsShellHtmlLoader({ + assets: binding, + devShellHtml: () => Promise.resolve(SHELL_HTML), + }); + + expect(await load()).toBe(SHELL_HTML); + expect(requests).toHaveLength(0); + }); + + it("falls through to the binding when the dev document is undefined (a build)", async () => { + const { binding } = bindingReturning([new Response(SHELL_HTML)]); + const load = makeAssetsShellHtmlLoader({ + assets: binding, + devShellHtml: () => Promise.resolve(undefined), + }); + + expect(await load()).toBe(SHELL_HTML); + }); +}); diff --git a/packages/hosts/mcp-apps-shell/src/worker.ts b/packages/hosts/mcp-apps-shell/src/worker.ts new file mode 100644 index 000000000..b161c2a2c --- /dev/null +++ b/packages/hosts/mcp-apps-shell/src/worker.ts @@ -0,0 +1,75 @@ +// The ambient declaration for `virtual:executor-mcp-apps-shell-dev-html` +// travels with this module into the Workers hosts that import it (their +// tsconfig `include` covers only their own sources) — same trick as +// `shell/artifact-renderer.tsx` and its `shell-html-url.d.ts`. +/// + +/** + * The shell loader for Workers hosts, where `loadMcpAppsShellHtml`'s + * `fs.readFile` can never succeed: a deployed isolate has no filesystem, so + * the document arrives the way #1378 delivers the worker-bundler artifact — + * emitted into the client assets at build time and fetched through the + * `ASSETS` binding at runtime. + * + * Kept free of the shell's own module graph (React, Recharts, Tailwind): this + * entry ships only a fetch and two constants, so a Worker can import it + * without dragging browser code into its bundle. + */ + +import { + MCP_APPS_SHELL_DOCUMENT_MARKER, + MCP_APPS_SHELL_STABLE_ASSET_PATH, +} from "./shell-asset-path"; + +/** The structural shape of a Workers static-assets binding (`env.ASSETS`). */ +export interface ShellAssetsBinding { + readonly fetch: (request: Request) => Promise; +} + +/** + * Build a `loadAppShellHtml` over the `ASSETS` binding. + * + * `devShellHtml` carries the document inline during `vite dev`, where no + * assets have been emitted yet: pass a thunk that dynamically imports + * `virtual:executor-mcp-apps-shell-dev-html` — the built shell under `serve`, + * `undefined` under `build`. A THUNK, not the value: under `serve` that + * virtual module builds the multi-megabyte shell in a child process, a cost + * only the first artifact resource read should pay, never dev-server + * startup. Same dual-shape seam #1378 uses for the worker-bundler artifact. + * + * Failure REJECTS, deliberately, on either a non-OK response or a document + * that is not the shell. The second check is load-bearing: under SPA + * not-found handling the binding answers a miss with the console's + * `index.html` and a 200, and serving THAT as the `ui://` resource is the + * silent every-client-hangs failure this seam exists to kill — the client + * loads a real document that never starts the MCP-Apps handshake. A rejected + * read surfaces as a JSON-RPC error naming the missing asset instead. + */ +export const makeAssetsShellHtmlLoader = (input: { + readonly assets: ShellAssetsBinding; + readonly devShellHtml?: () => Promise; +}): (() => Promise) => { + let cache: string | undefined; + return async () => { + if (cache !== undefined) return cache; + const devHtml = await input.devShellHtml?.(); + if (devHtml !== undefined) { + cache = devHtml; + return cache; + } + const response = await input.assets.fetch( + new Request(new URL(MCP_APPS_SHELL_STABLE_ASSET_PATH, "https://assets.local")), + ); + const html = response.ok ? await response.text() : undefined; + if (html === undefined || !html.includes(MCP_APPS_SHELL_DOCUMENT_MARKER)) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: a Worker without the emitted shell asset must fail the resource read loudly; any other document served as the shell hangs every MCP-Apps client silently + throw new Error( + `MCP-Apps shell asset ${MCP_APPS_SHELL_STABLE_ASSET_PATH} is missing from this deployment's ` + + `static assets (${html === undefined ? `ASSETS fetch returned ${response.status}` : "the fetch returned a different document"}). ` + + "The app build emits it via mcpAppsShellAsset (@executor-js/mcp-apps-shell/vite); rebuild and redeploy.", + ); + } + cache = html; + return cache; + }; +}; diff --git a/packages/hosts/mcp-apps-shell/tsconfig.json b/packages/hosts/mcp-apps-shell/tsconfig.json index 1474eac8d..2777db0af 100644 --- a/packages/hosts/mcp-apps-shell/tsconfig.json +++ b/packages/hosts/mcp-apps-shell/tsconfig.json @@ -3,6 +3,7 @@ "target": "ESNext", "module": "ESNext", "moduleResolution": "bundler", + "allowImportingTsExtensions": true, "strict": true, "esModuleInterop": true, "skipLibCheck": true,