History

Serve the MCP-Apps shell from static assets on Workers hosts

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).

Rhys Sullivan committed 3 months ago

Commit 1e50ff31 parent 6a801f223 files · +451 −58Browse files

Unified · Split

23 files
  1. .changeset/violet-rules-hang.md +7 −0
  2. apps/cli/src/build.ts +4 −4
  3. apps/cloud/scripts/build.mjs +20 −1
  4. apps/cloud/src/mcp/session-durable-object.ts +14 −2
  5. apps/host-cloudflare/package.json +2 −2
  6. apps/host-cloudflare/scripts/assert-shell-asset.mjs +17 −0
  7. apps/host-cloudflare/src/config.ts +5 −1
  8. apps/host-cloudflare/src/mcp/session-durable-object.ts +11 −2
  9. apps/host-cloudflare/wrangler.jsonc +4 −0
  10. apps/host-selfhost/package.json +1 −1
  11. apps/host-selfhost/scripts/assert-shell-asset.mjs +18 −0
  12. packages/hosts/mcp-apps-shell/package.json +4 −0
  13. packages/hosts/mcp-apps-shell/src/index.ts +6 −4
  14. packages/hosts/mcp-apps-shell/src/shell-asset-path.ts +35 −0
  15. packages/hosts/mcp-apps-shell/src/shell-dev-html.d.ts +14 −0
  16. packages/hosts/mcp-apps-shell/src/shell-html.ts +50 −32
  17. packages/hosts/mcp-apps-shell/src/shell-resource.smoke.test.ts +13 −8
  18. packages/hosts/mcp-apps-shell/src/shell/mcp-app.html +5 −0
  19. packages/hosts/mcp-apps-shell/src/vite.test.ts +11 −0
  20. packages/hosts/mcp-apps-shell/src/vite.ts +49 −1
  21. packages/hosts/mcp-apps-shell/src/worker.test.ts +85 −0
  22. packages/hosts/mcp-apps-shell/src/worker.ts +75 −0
  23. packages/hosts/mcp-apps-shell/tsconfig.json +1 −0
.changeset/violet-rules-hang.md+7−0
@@ -0,0 +1,7 @@
1+---
2+"executor": patch
3+---
4+
5+**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**
6+
7+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.
apps/cli/src/build.ts+4−4
@@ -31,10 +31,10 @@ const resolveQuickJsWasmPath = (): string => { · 30 unmodified lines
3131 // The MCP host serves the shell as the `ui://executor/shell.html` resource, and
3232 // `@executor-js/mcp-apps-shell` reads it from disk at runtime. That runtime
3333 // `fs.readFile` is invisible to `bun build --compile`, so without this the
34−// packaged binary would serve the "Shell not built" placeholder and every
35−// generated UI would render blank. Build it if missing and copy it next to the
36−// executable, where the loader finds it via `process.execPath` — the same
37−// colocation trick used for `libsql.node` / `emscripten-module.wasm`.
34+// packaged binary would fail every artifact resource read. Build it if missing
35+// and copy it next to the executable, where the loader finds it via
36+// `process.execPath` — the same colocation trick used for `libsql.node` /
37+// `emscripten-module.wasm`.
3838 // ---------------------------------------------------------------------------
3939
4040 const mcpAppsShellDir = resolve(repoRoot, "packages/hosts/mcp-apps-shell");
apps/cloud/scripts/build.mjs+20−1
@@ -9,7 +9,7 @@ · 8 unmodified lines
99
1010 import { spawnSync } from "node:child_process";
1111 import { randomBytes } from "node:crypto";
12−import { rmSync } from "node:fs";
12+import { existsSync, readFileSync, rmSync } from "node:fs";
1313
1414 if (!process.env.VITE_PUBLIC_ANALYTICS_PATH) {
1515 process.env.VITE_PUBLIC_ANALYTICS_PATH = randomBytes(4).toString("hex");
@@ -32,3 +32,22 @@ for (const step of steps) { · 16 unmodified lines
3232 process.exit(result.status ?? 1);
3333 }
3434 }
35+
36+// The deployed Worker serves the MCP-Apps shell (`ui://executor/shell.html`)
37+// by fetching this stable-named asset through the ASSETS binding — it has no
38+// filesystem to read the shell from. A build that failed to emit it would
39+// deploy a Worker whose artifact resource reads all fail, so make the miss a
40+// build failure here instead of a production incident. Same shape as the
41+// worker-bundler asset assertion from #1378.
42+const shellAsset = new URL(
43+ "../dist/client/assets/executor-mcp-apps-shell-stable.html",
44+ import.meta.url,
45+);
46+if (!existsSync(shellAsset)) {
47+ console.error(`[build] missing MCP-Apps shell asset at ${shellAsset.pathname}`);
48+ process.exit(1);
49+}
50+if (!readFileSync(shellAsset, "utf8").includes('name="executor-mcp-apps-shell"')) {
51+ console.error(`[build] ${shellAsset.pathname} is not the MCP-Apps shell document`);
52+ process.exit(1);
53+}
apps/cloud/src/mcp/session-durable-object.ts+14−2
@@ -26,7 +26,7 @@ import { · 25 unmodified lines
2626 } from "@executor-js/host-mcp/tool-server";
2727 import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval";
2828 import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact";
29−import { loadMcpAppsShellHtml } from "@executor-js/mcp-apps-shell";
29+import { makeAssetsShellHtmlLoader } from "@executor-js/mcp-apps-shell/worker";
3030 import { smokeRenderArtifact } from "@executor-js/mcp-apps-shell/smoke-render";
3131 import {
3232 McpAgentSessionDOBase,
@@ -156,6 +156,18 @@ const makeSessionServices = (dbHandle: CloudSessionDbHandle) => { · 123 unmodified lines
156156 return Layer.mergeAll(DbLive, UserStoreLive, CoreSharedServices);
157157 };
158158
159+// The `ui://executor/shell.html` resource, over the ASSETS binding: the
160+// deployed Worker has no filesystem, so the document is the stable-named
161+// asset the client build emitted (`mcpAppsShellAsset`), fetched at first
162+// artifact resource read. Module scope so the fetch-and-verify happens once
163+// per isolate, not once per session. The dev thunk carries the built shell
164+// inline under `vite dev`, where no assets exist yet for the binding to find.
165+const loadAppShellHtml = makeAssetsShellHtmlLoader({
166+ assets: env.ASSETS,
167+ devShellHtml: () =>
168+ import("virtual:executor-mcp-apps-shell-dev-html").then((mod) => mod.devShellHtml),
169+});
170+
159171 // ---------------------------------------------------------------------------
160172 // Durable Object
161173 // ---------------------------------------------------------------------------
@@ -268,7 +280,7 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 106 unmodified lines
268280 // flag existed carry no value; absent now means off, same as a fresh
269281 // connection that did not ask for `?artifacts=true`.
270282 artifactsEnabled: sessionMeta.artifactsEnabled ?? false,
271− loadAppShellHtml: loadMcpAppsShellHtml,
283+ loadAppShellHtml,
272284 smokeRenderArtifact,
273285 artifactUrl: artifactUrlFor(
274286 env.VITE_PUBLIC_SITE_URL ?? "https://executor.sh",
apps/host-cloudflare/package.json+2−2
@@ -3,8 +3,8 @@ · 2 unmodified lines
33 "private": true,
44 "type": "module",
55 "scripts": {
6− "build": "vite build",
7− "deploy": "vite build && wrangler deploy",
6+ "build": "vite build && node scripts/assert-shell-asset.mjs",
7+ "deploy": "vite build && node scripts/assert-shell-asset.mjs && wrangler deploy",
88 "dev": "wrangler dev",
99 "dev:web": "vite dev",
1010 "typecheck": "tsgo --noEmit",
apps/host-cloudflare/scripts/assert-shell-asset.mjs+17−0
@@ -0,0 +1,17 @@
1+// Build-blocking check that the SPA build emitted the stable-named MCP-Apps
2+// shell document. The MCP session DO serves `ui://executor/shell.html` by
3+// fetching this asset through the ASSETS binding — a deployed Worker has no
4+// filesystem — so a build without it ships a Worker whose artifact resource
5+// reads all fail. Fail the build here instead. Same shape as apps/cloud's
6+// assertion in scripts/build.mjs.
7+import { existsSync, readFileSync } from "node:fs";
8+
9+const shellAsset = new URL("../dist/assets/executor-mcp-apps-shell-stable.html", import.meta.url);
10+if (!existsSync(shellAsset)) {
11+ console.error(`[build] missing MCP-Apps shell asset at ${shellAsset.pathname}`);
12+ process.exit(1);
13+}
14+if (!readFileSync(shellAsset, "utf8").includes('name="executor-mcp-apps-shell"')) {
15+ console.error(`[build] ${shellAsset.pathname} is not the MCP-Apps shell document`);
16+ process.exit(1);
17+}
apps/host-cloudflare/src/config.ts+5−1
@@ -21,6 +21,10 @@ export interface CloudflareEnv { · 20 unmodified lines
2121 readonly DB: D1Database;
2222 /** R2 bucket binding — holds values too large for a D1 row (~1-2MB cap). */
2323 readonly BLOBS?: R2Bucket;
24+ /** Static assets binding (wrangler.jsonc `assets.binding`). The MCP session
25+ * DO fetches the built MCP-Apps shell document through it — a deployed
26+ * Worker has no filesystem to read the shell from. */
27+ readonly ASSETS: { readonly fetch: (request: Request) => Promise<Response> };
2428 /** MCP session Durable Object namespace — one addressable isolate per MCP
2529 * session (the DO id IS the session id), so a session survives across the
2630 * Worker's stateless isolates. */
@@ -74,7 +78,7 @@ export interface CloudflareConfig { · 47 unmodified lines
7478
7579 type CloudflareConfigEnv = Omit<
7680 CloudflareEnv,
77− "DB" | "BLOBS" | "MCP_SESSION" | "MCP_EXECUTION_OWNER"
81+ "DB" | "BLOBS" | "ASSETS" | "MCP_SESSION" | "MCP_EXECUTION_OWNER"
7882 >;
7983
8084 type CloudflareAccessEnv = Pick<
apps/host-cloudflare/src/mcp/session-durable-object.ts+11−2
@@ -6,7 +6,7 @@ import { · 5 unmodified lines
66 } from "@executor-js/host-mcp/tool-server";
77 import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval";
88 import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact";
9−import { loadMcpAppsShellHtml } from "@executor-js/mcp-apps-shell";
9+import { makeAssetsShellHtmlLoader } from "@executor-js/mcp-apps-shell/worker";
1010 import { smokeRenderArtifact } from "@executor-js/mcp-apps-shell/smoke-render";
1111 import type { ExecutorDbHandle } from "@executor-js/api/server";
1212 import {
@@ -57,6 +57,14 @@ class McpModelResumeForwardError extends Data.TaggedError("McpModelResumeForward · 44 unmodified lines
5757 export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSessionDbHandle> {
5858 private readonly cfEnv: CloudflareEnv;
5959 private readonly cfConfig: CloudflareConfig;
60+ /**
61+ * The `ui://executor/shell.html` document, over the ASSETS binding: a
62+ * deployed Worker has no filesystem, so the shell is the stable-named asset
63+ * the SPA build emitted into `./dist` (`mcpAppsShellAsset`). No dev-mode
64+ * escape hatch here, unlike cloud: this Worker is bundled by wrangler, not
65+ * Vite, and `wrangler dev` serves the same built `./dist` the binding reads.
66+ */
67+ private readonly loadAppShellHtml: () => Promise<string>;
6068
6169 constructor(
6270 ctx: ConstructorParameters<typeof McpAgentSessionDOBase<CloudflareEnv, CfSessionDbHandle>>[0],
@@ -65,6 +73,7 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession · 2 unmodified lines
6573 super(ctx, env);
6674 this.cfEnv = env;
6775 this.cfConfig = loadConfig(env);
76+ this.loadAppShellHtml = makeAssetsShellHtmlLoader({ assets: env.ASSETS });
6877 }
6978
7079 protected override executionOwnerDirectory(): McpExecutionOwnerDirectory | null {
@@ -140,7 +149,7 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession · 69 unmodified lines
140149 // flag existed carry no value; absent now means off, same as a fresh
141150 // connection that did not ask for `?artifacts=true`.
142151 artifactsEnabled: sessionMeta.artifactsEnabled ?? false,
143− loadAppShellHtml: loadMcpAppsShellHtml,
152+ loadAppShellHtml: self.loadAppShellHtml,
144153 smokeRenderArtifact,
145154 ...(artifactOrigin
146155 ? { artifactUrl: artifactUrlFor(artifactOrigin, sessionMeta.organizationSlug) }
apps/host-cloudflare/wrangler.jsonc+4−0
@@ -15,6 +15,10 @@ · 14 unmodified lines
1515 // Worker instead of the SPA fallback.
1616 "assets": {
1717 "directory": "./dist",
18+ // The MCP session DO reads the built MCP-Apps shell document through this
19+ // binding (`ui://executor/shell.html` has no filesystem to come from on a
20+ // deployed Worker).
21+ "binding": "ASSETS",
1822 "not_found_handling": "single-page-application",
1923 "run_worker_first": ["/api/*", "/mcp", "/mcp/*", "/.well-known/*", "/v1", "/v1/*"],
2024 },
apps/host-selfhost/package.json+1−1
@@ -10,7 +10,7 @@ · 9 unmodified lines
1010 },
1111 "scripts": {
1212 "dev": "bunx --bun vite dev",
13− "build": "turbo run build --filter @executor-js/vite-plugin && vite build",
13+ "build": "turbo run build --filter @executor-js/vite-plugin && vite build && node scripts/assert-shell-asset.mjs",
1414 "start": "bun run src/serve.ts",
1515 "typecheck": "tsgo --noEmit",
1616 "test": "vitest run",
apps/host-selfhost/scripts/assert-shell-asset.mjs+18−0
@@ -0,0 +1,18 @@
1+// Build-blocking check that the SPA build emitted the stable-named MCP-Apps
2+// shell document. The self-host server serves `ui://executor/shell.html` by
3+// reading this file from the SPA dist (`loadMcpAppsShellHtml`'s image-layout
4+// candidate) — the runtime image carries no `packages/` tree for the package
5+// dist candidates to hit — so a build without it ships an image whose
6+// artifact resource reads all fail. Fail the build here instead. Same shape
7+// as apps/cloud's assertion in scripts/build.mjs.
8+import { existsSync, readFileSync } from "node:fs";
9+
10+const shellAsset = new URL("../dist/assets/executor-mcp-apps-shell-stable.html", import.meta.url);
11+if (!existsSync(shellAsset)) {
12+ console.error(`[build] missing MCP-Apps shell asset at ${shellAsset.pathname}`);
13+ process.exit(1);
14+}
15+if (!readFileSync(shellAsset, "utf8").includes('name="executor-mcp-apps-shell"')) {
16+ console.error(`[build] ${shellAsset.pathname} is not the MCP-Apps shell document`);
17+ process.exit(1);
18+}
packages/hosts/mcp-apps-shell/package.json+4−0
@@ -12,6 +12,10 @@ · 11 unmodified lines
1212 "types": "./src/shell-html.ts",
1313 "default": "./src/shell-html.ts"
1414 },
15+ "./worker": {
16+ "types": "./src/worker.ts",
17+ "default": "./src/worker.ts"
18+ },
1519 "./smoke-render": {
1620 "types": "./src/smoke-render-entry.ts",
1721 "default": "./src/smoke-render-entry.ts"
packages/hosts/mcp-apps-shell/src/index.ts+6−4
@@ -2,10 +2,12 @@ · 1 unmodified line
22 * The MCP-Apps shell: the trusted iframe host that renders model-written JSX.
33 *
44 * The shell reaches MCP clients as a single self-contained HTML document (the
5− * `ui://executor/shell.html` resource). Hosts read it with
6− * `loadMcpAppsShellHtml` and hand it to the MCP host through the
5+ * `ui://executor/shell.html` resource), handed to the MCP host through the
76 * `loadAppShellHtml` config seam — the MCP host never imports this package
8− * directly, so React/Recharts/Tailwind stay out of the Workers graph.
7+ * directly, so React/Recharts/Tailwind stay out of the Workers graph. Hosts
8+ * with a filesystem read it with `loadMcpAppsShellHtml`; Workers hosts fetch
9+ * it through their ASSETS binding with `makeAssetsShellHtmlLoader`
10+ * (`@executor-js/mcp-apps-shell/worker`).
911 */
1012
11−export { loadMcpAppsShellHtml, MCP_APPS_SHELL_NOT_BUILT_HTML } from "./shell-html";
13+export { loadMcpAppsShellHtml } from "./shell-html";
packages/hosts/mcp-apps-shell/src/shell-asset-path.ts+35−0
@@ -0,0 +1,35 @@
1+/**
2+ * The names the built shell document travels under, shared by the build-time
3+ * emitter (`./vite`) and the runtime loaders (`./shell-html`, `./worker`).
4+ *
5+ * In its own module so runtime loaders can import the constants without
6+ * dragging in the build-time plugin machinery (esbuild, child-process Vite).
7+ */
8+
9+/**
10+ * Where `mcpAppsShellAsset` emits the STABLE-NAMED copy of the built shell
11+ * document, alongside the content-hashed one.
12+ *
13+ * The hashed name exists for browsers: the console's artifact page loads the
14+ * shell by URL, and a content hash is what lets that URL be cached forever. A
15+ * Worker serving the `ui://executor/shell.html` MCP resource has the opposite
16+ * problem — it fetches the document server-side through the ASSETS binding,
17+ * at runtime, from a bundle that cannot know the hash the client build
18+ * produced. A second copy at a name both sides agree on ahead of time is the
19+ * seam. No staleness risk: the binding serves the current deployment's
20+ * assets, not a browser cache keyed on the URL.
21+ */
22+export const MCP_APPS_SHELL_STABLE_ASSET_PATH = "/assets/executor-mcp-apps-shell-stable.html";
23+
24+/**
25+ * A fragment every built shell document contains (the `<meta>` in
26+ * `src/shell/mcp-app.html`), and no other document does.
27+ *
28+ * Exists because a fetch through a Workers ASSETS binding cannot be trusted
29+ * by status alone: with `not_found_handling: "single-page-application"`
30+ * (host-cloudflare), a miss returns the console's `index.html` with a 200.
31+ * Serving THAT as the shell resource would be the exact silent-hang failure
32+ * this seam exists to kill — the client loads a real document that never
33+ * speaks the MCP-Apps protocol. Loaders verify the marker and fail loudly.
34+ */
35+export const MCP_APPS_SHELL_DOCUMENT_MARKER = 'name="executor-mcp-apps-shell"';
packages/hosts/mcp-apps-shell/src/shell-dev-html.d.ts+14−0
@@ -0,0 +1,14 @@
1+/**
2+ * The built shell document's BYTES under `vite dev`, or `undefined` in a
3+ * build — supplied by `mcpAppsShellAsset` (`@executor-js/mcp-apps-shell/vite`).
4+ *
5+ * Only Workers hosts import this, as the `devShellHtml` thunk for
6+ * `makeAssetsShellHtmlLoader`: in dev no assets have been emitted for the
7+ * ASSETS binding to find, so the document travels inline; in a deployed
8+ * build this module is `undefined` and the binding is the real path.
9+ */
10+declare module "virtual:executor-mcp-apps-shell-dev-html" {
11+ const devShellHtml: string | undefined;
12+ export { devShellHtml };
13+ export default devShellHtml;
14+}
packages/hosts/mcp-apps-shell/src/shell-html.ts+50−32
@@ -1,49 +1,67 @@
1+import { MCP_APPS_SHELL_STABLE_ASSET_PATH } from "./shell-asset-path";
2+
13 let shellHtmlCache: string | undefined;
24
35 /**
4− * The self-contained shell HTML served as the MCP-Apps `ui://` resource.
6+ * The self-contained shell HTML served as the MCP-Apps `ui://` resource, read
7+ * from the local filesystem.
58 *
69 * Hosts inject this through `SharedMcpServerConfig.loadAppShellHtml` rather
710 * than the MCP host package importing it directly: the shell drags React,
811 * Recharts and Tailwind into whatever graph imports it, and the MCP host also
912 * runs on Workers where none of that belongs.
13+ *
14+ * This loader is for hosts WITH a filesystem: the local CLI/desktop binary
15+ * (shell copied next to the executable by `apps/cli/src/build.ts`), dev runs
16+ * from the repo, and the self-host image (the stable-named copy its SPA build
17+ * emits into `dist/assets/`). Workers hosts use `makeAssetsShellHtmlLoader`
18+ * from `@executor-js/mcp-apps-shell/worker` instead — `fs.readFile` can never
19+ * succeed there.
20+ *
21+ * A missing shell REJECTS, deliberately. This used to degrade to a "Shell not
22+ * built" placeholder, which is the worst possible failure mode: the resource
23+ * read succeeds, the client loads an inert document that never starts the
24+ * MCP-Apps handshake, and every artifact spins on its loading state forever
25+ * with no error anywhere in the chain — which is exactly how production
26+ * served the placeholder unnoticed. A rejected read surfaces as a JSON-RPC
27+ * error naming the missing file and the command that produces it.
1028 */
1129 export const loadMcpAppsShellHtml = async (): Promise<string> => {
1230 if (shellHtmlCache) return shellHtmlCache;
1331
14− // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: optional prebuilt shell asset is loaded from local filesystem when present
15− try {
16− const fs = await import("node:fs/promises");
17− const path = await import("node:path");
18− const candidates = [
19− // `bun build --compile` can't bundle this runtime `fs.readFile`, so the
20− // binary build (apps/cli/src/build.ts) copies `mcp-app.html` next to the
21− // executable. We find it via `process.execPath`, the same colocation
22− // trick native-bindings.ts uses for `libsql.node` / `keyring.node`.
23− path.join(path.dirname(process.execPath), "mcp-app.html"),
24− // Dev / package-resolved (`bun run`, vitest): the package's own dist.
25− path.join(import.meta.dirname, "../dist/mcp-app.html"),
26− path.join(import.meta.dirname, "../../dist/mcp-app.html"),
27− ];
32+ const fs = await import("node:fs/promises");
33+ const path = await import("node:path");
34+ const candidates = [
35+ // `bun build --compile` can't bundle this runtime `fs.readFile`, so the
36+ // binary build (apps/cli/src/build.ts) copies `mcp-app.html` next to the
37+ // executable. We find it via `process.execPath`, the same colocation
38+ // trick native-bindings.ts uses for `libsql.node` / `keyring.node`.
39+ path.join(path.dirname(process.execPath), "mcp-app.html"),
40+ // Dev / package-resolved (`bun run`, vitest): the package's own dist.
41+ path.join(import.meta.dirname, "../dist/mcp-app.html"),
42+ path.join(import.meta.dirname, "../../dist/mcp-app.html"),
43+ // The self-host runtime image. Its server is bundled to
44+ // `apps/host-selfhost/dist-server/serve.js`, and the sibling `dist/` is
45+ // the SPA build, whose `mcpAppsShellAsset` emits the stable-named copy of
46+ // the shell into `dist/assets/`. The package-dist candidates above can't
47+ // hit there — the image carries no `packages/` tree at all.
48+ path.join(import.meta.dirname, "../dist", MCP_APPS_SHELL_STABLE_ASSET_PATH),
49+ ];
2850
29− for (const candidate of candidates) {
30− // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: try each possible emitted shell path before falling back
31− try {
32− shellHtmlCache = await fs.readFile(candidate, "utf-8");
33− return shellHtmlCache;
34− } catch {
35− // Try the next candidate path.
36− }
51+ for (const candidate of candidates) {
52+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: try each possible emitted shell path before failing
53+ try {
54+ shellHtmlCache = await fs.readFile(candidate, "utf-8");
55+ return shellHtmlCache;
56+ } catch {
57+ // Try the next candidate path.
3758 }
38− } catch {
39− // Fall through to the development fallback below.
4059 }
4160
42− shellHtmlCache = MCP_APPS_SHELL_NOT_BUILT_HTML;
43− return shellHtmlCache;
61+ // 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
62+ throw new Error(
63+ "MCP-Apps shell document not found. Tried:\n" +
64+ candidates.map((candidate) => ` ${candidate}`).join("\n") +
65+ "\nBuild it with: bun run --cwd packages/hosts/mcp-apps-shell build:shell",
66+ );
4467 };
45−
46−/** What the loader serves when no built shell is on disk. Exported so tests can
47− * assert a host is serving the real thing rather than this placeholder. */
48−export const MCP_APPS_SHELL_NOT_BUILT_HTML =
49− "<!doctype html><html><body><p>Shell not built. Run: bun run --cwd packages/hosts/mcp-apps-shell build:shell</p></body></html>";
packages/hosts/mcp-apps-shell/src/shell-resource.smoke.test.ts+13−8
@@ -8,14 +8,16 @@ import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server"; · 7 unmodified lines
88 import { MCP_APPS_SHELL_RESOURCE_URI } from "@executor-js/host-mcp/create-artifact";
99 import type { ExecutionEngine } from "@executor-js/execution";
1010
11−import { loadMcpAppsShellHtml, MCP_APPS_SHELL_NOT_BUILT_HTML } from "./shell-html";
11+import { loadMcpAppsShellHtml } from "./shell-html";
12+import { MCP_APPS_SHELL_DOCUMENT_MARKER } from "./shell-asset-path";
1213
1314 // The shipped binary serves the shell from a file on disk (`build:shell` output,
1415 // copied next to the executable by `apps/cli/src/build.ts`). If that file is
15−// missing the loader silently degrades to a placeholder and every generated UI
16−// renders blank — so assert an MCP client reading the resource gets the REAL
17−// built shell, not the placeholder. This replaces a build-time subprocess probe
18−// that hard-slept 7 seconds inside every binary build.
16+// missing the loader rejects the resource read — it used to degrade to a
17+// placeholder document, which hung every MCP-Apps client silently — so assert
18+// an MCP client reading the resource gets the REAL built shell. This replaces
19+// a build-time subprocess probe that hard-slept 7 seconds inside every binary
20+// build.
1921
2022 const stubEngine: ExecutionEngine<never> = {
2123 execute: () => Effect.succeed({ result: "ok" }),
@@ -34,7 +36,7 @@ const APPS_CAPS = { · 12 unmodified lines
3436 } as unknown as ClientCapabilities;
3537
3638 describe("MCP-Apps shell resource", () => {
37− it("serves the built shell to an MCP client, not the 'Shell not built' placeholder", async () => {
39+ it("serves the built shell to an MCP client", async () => {
3840 const mcpServer = await Effect.runPromise(
3941 // Artifacts are opt-in per connection; the shell resource only exists on
4042 // a session that asked for them.
@@ -58,9 +60,12 @@ describe("MCP-Apps shell resource", () => { · 17 unmodified lines
5860 // The shell is served as text, never as a blob.
5961 expect("text" in content).toBe(true);
6062 const html = "text" in content ? content.text : "";
61− expect(html).not.toBe(MCP_APPS_SHELL_NOT_BUILT_HTML);
62− expect(html).not.toContain("Shell not built");
6363 expect(html).toContain('id="root"');
64+ // The identity marker the Workers loader authenticates the document by
65+ // (an ASSETS-binding miss under SPA not-found handling answers with
66+ // index.html and a 200). Asserted here so the marker leaving the template
67+ // fails this suite too, not just the app builds.
68+ expect(html).toContain(MCP_APPS_SHELL_DOCUMENT_MARKER);
6469 // `vite-plugin-singlefile` inlines every asset: a shell that still points at
6570 // a sibling bundle would 404 inside the host's sandboxed iframe.
6671 //
packages/hosts/mcp-apps-shell/src/shell/mcp-app.html+5−0
@@ -3,6 +3,11 @@ · 2 unmodified lines
33 <head>
44 <meta charset="UTF-8" />
55 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+ <!-- Identity marker for runtime loaders: an ASSETS-binding fetch under
7+ SPA not-found handling answers a miss with index.html and a 200, so
8+ loaders verify this fragment before serving the document as the
9+ ui:// shell resource. See MCP_APPS_SHELL_DOCUMENT_MARKER. -->
10+ <meta name="executor-mcp-apps-shell" content="shell" />
611 <title>Executor Shell</title>
712 </head>
813 <body>
packages/hosts/mcp-apps-shell/src/vite.test.ts+11−0
@@ -14,4 +14,15 @@ describe("mcpAppsShellAsset", () => { · 13 unmodified lines
1414 "export default shellHtmlUrl;\n",
1515 );
1616 });
17+
18+ it("resolves the dev-html module to undefined in a build, so Workers use the ASSETS binding", async () => {
19+ const plugin = mcpAppsShellAsset();
20+ plugin.configResolved({ command: "build" });
21+
22+ const resolved = plugin.resolveId("virtual:executor-mcp-apps-shell-dev-html");
23+ expect(resolved).toBeDefined();
24+ expect(await plugin.load(resolved ?? "")).toBe(
25+ "export const devShellHtml = undefined;\nexport default devShellHtml;\n",
26+ );
27+ });
1728 });
packages/hosts/mcp-apps-shell/src/vite.ts+49−1
@@ -25,6 +25,11 @@ import { createHash } from "node:crypto"; · 24 unmodified lines
2525 import path from "node:path";
2626 import { fileURLToPath } from "node:url";
2727
28+import {
29+ MCP_APPS_SHELL_DOCUMENT_MARKER,
30+ MCP_APPS_SHELL_STABLE_ASSET_PATH,
31+} from "./shell-asset-path.ts";
32+
2833 /** The structural shape of the Vite plugin object, declared locally so this
2934 * module imports no Vite types (apps pin their own Vite version). */
3035 export interface InnerRendererVitePlugin {
@@ -172,6 +177,12 @@ export const tailwindBrowserSourcePlugin = (): InnerRendererVitePlugin => ({ · 141 unmodified lines
172177 const SHELL_VIRTUAL_ID = "virtual:executor-mcp-apps-shell-html";
173178 const SHELL_RESOLVED_ID = `\0${SHELL_VIRTUAL_ID}`;
174179 const DEV_SHELL_URL = "/assets/executor-mcp-apps-shell-dev.html";
180+// The document's BYTES for the Worker in dev, where the stable asset does not
181+// exist yet (assets are only emitted by a build) and the dev ASSETS binding
182+// resolves against files on disk. `makeAssetsShellHtmlLoader` imports this
183+// lazily, so the shell only builds when an artifact resource is actually read.
184+const DEV_HTML_VIRTUAL_ID = "virtual:executor-mcp-apps-shell-dev-html";
185+const DEV_HTML_RESOLVED_ID = `\0${DEV_HTML_VIRTUAL_ID}`;
175186
176187 /** Where `build:shell` writes the self-contained shell document. */
177188 export const mcpAppsShellHtmlPath = (): string => path.resolve(packageRoot, "dist/mcp-app.html");
@@ -283,6 +294,17 @@ export const mcpAppsShellAsset = (): McpAppsShellAssetVitePlugin => { · 105 unmodified lines
283294 const load = async (): Promise<{ readonly html: string; readonly url: string }> => {
284295 if (cached) return cached;
285296 const html = await buildMcpAppsShellHtml();
297+ // Build-blocking: runtime loaders authenticate the document by this
298+ // fragment (an ASSETS-binding miss under SPA not-found handling answers
299+ // with index.html and a 200), so a shell that lost it would make every
300+ // deployed resource read fail. Catch that here, in the build.
301+ if (!html.includes(MCP_APPS_SHELL_DOCUMENT_MARKER)) {
302+ // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: Vite plugin hooks report build failures by throwing
303+ throw new Error(
304+ `Built MCP-Apps shell is missing its identity marker (${MCP_APPS_SHELL_DOCUMENT_MARKER}); ` +
305+ "check the <meta> in src/shell/mcp-app.html.",
306+ );
307+ }
286308 const digest = createHash("sha256").update(html).digest("hex").slice(0, 12);
287309 cached = { html, url: `/assets/executor-mcp-apps-shell-${digest}.html` };
288310 return cached;
@@ -300,8 +322,24 @@ export const mcpAppsShellAsset = (): McpAppsShellAssetVitePlugin => { · 11 unmodified lines
300322 // only carries the single-environment case, where the two agree.
301323 ssr = Boolean(config.build?.ssr);
302324 },
303− resolveId: (id) => (id === SHELL_VIRTUAL_ID ? SHELL_RESOLVED_ID : undefined),
325+ resolveId: (id) => {
326+ if (id === SHELL_VIRTUAL_ID) return SHELL_RESOLVED_ID;
327+ if (id === DEV_HTML_VIRTUAL_ID) return DEV_HTML_RESOLVED_ID;
328+ return undefined;
329+ },
304330 load: async (id) => {
331+ if (id === DEV_HTML_RESOLVED_ID) {
332+ // The Worker's dev-only escape hatch: under `build` it exports
333+ // `undefined` and `makeAssetsShellHtmlLoader` uses the ASSETS binding;
334+ // under `serve` it carries the built document inline, because no
335+ // assets have been emitted for the binding to find. Guarded the same
336+ // way as the URL module below — the build must not run at startup.
337+ if (command !== "serve") {
338+ return "export const devShellHtml = undefined;\nexport default devShellHtml;\n";
339+ }
340+ const { html } = await load();
341+ return `export const devShellHtml = ${JSON.stringify(html)};\nexport default devShellHtml;\n`;
342+ }
305343 if (id !== SHELL_RESOLVED_ID) return undefined;
306344 // Resolving the virtual module is part of Vite's startup graph. Building
307345 // the multi-megabyte shell here makes every dev server pay that cost
@@ -355,6 +393,16 @@ export const mcpAppsShellAsset = (): McpAppsShellAssetVitePlugin => { · 47 unmodified lines
355393 // configured — which `process.cwd()`-relative writes get wrong for
356394 // TanStack Start, where the client build writes to `dist/client`.
357395 this.emitFile({ type: "asset", fileName: url.replace(/^\//, ""), source: html });
396+ // A second copy at the STABLE name, for the server side: a Workers host
397+ // serves the `ui://executor/shell.html` MCP resource by fetching this
398+ // through its ASSETS binding (`makeAssetsShellHtmlLoader`), and the
399+ // self-host image reads it from the SPA dist on disk — neither can know
400+ // the content hash the client build minted. See `./shell-asset-path`.
401+ this.emitFile({
402+ type: "asset",
403+ fileName: MCP_APPS_SHELL_STABLE_ASSET_PATH.replace(/^\//, ""),
404+ source: html,
405+ });
358406 },
359407 async closeBundle() {
360408 cached = undefined;
packages/hosts/mcp-apps-shell/src/worker.test.ts+85−0
@@ -0,0 +1,85 @@
1+import { describe, expect, it } from "@effect/vitest";
2+
3+import { makeAssetsShellHtmlLoader, type ShellAssetsBinding } from "./worker";
4+import {
5+ MCP_APPS_SHELL_DOCUMENT_MARKER,
6+ MCP_APPS_SHELL_STABLE_ASSET_PATH,
7+} from "./shell-asset-path";
8+
9+const SHELL_HTML = `<!doctype html><html><head><meta ${MCP_APPS_SHELL_DOCUMENT_MARKER} content="shell"></head><body><div id="root"></div></body></html>`;
10+
11+const bindingReturning = (
12+ responses: readonly Response[],
13+): { readonly binding: ShellAssetsBinding; readonly requests: Request[] } => {
14+ const requests: Request[] = [];
15+ let call = 0;
16+ return {
17+ requests,
18+ binding: {
19+ fetch: (request) => {
20+ requests.push(request);
21+ const response = responses[Math.min(call, responses.length - 1)];
22+ call += 1;
23+ // Running out of scripted responses means the test was wrong about
24+ // how many fetches the loader makes; a distinctive 599 makes the
25+ // resulting assertion failure say so.
26+ return Promise.resolve(
27+ response ?? new Response("stub exhausted: unexpected fetch", { status: 599 }),
28+ );
29+ },
30+ },
31+ };
32+};
33+
34+describe("makeAssetsShellHtmlLoader", () => {
35+ it("serves the shell from the stable asset path and caches it", async () => {
36+ const { binding, requests } = bindingReturning([new Response(SHELL_HTML)]);
37+ const load = makeAssetsShellHtmlLoader({ assets: binding });
38+
39+ expect(await load()).toBe(SHELL_HTML);
40+ expect(await load()).toBe(SHELL_HTML);
41+ expect(requests).toHaveLength(1);
42+ expect(new URL(requests[0]?.url ?? "").pathname).toBe(MCP_APPS_SHELL_STABLE_ASSET_PATH);
43+ });
44+
45+ it("rejects on a non-OK response instead of serving a substitute document", async () => {
46+ const { binding } = bindingReturning([new Response("not found", { status: 404 })]);
47+ const load = makeAssetsShellHtmlLoader({ assets: binding });
48+
49+ await expect(load()).rejects.toThrow(/404/);
50+ await expect(load()).rejects.toThrow(MCP_APPS_SHELL_STABLE_ASSET_PATH);
51+ });
52+
53+ it("rejects a 200 that is not the shell (SPA not-found fallback)", async () => {
54+ // Under `not_found_handling: "single-page-application"` a miss answers
55+ // with the console's index.html and a 200 — the exact document that, if
56+ // served as the ui:// resource, hangs every client with no error.
57+ const { binding } = bindingReturning([
58+ new Response("<!doctype html><html><body><div id='app'>console</div></body></html>"),
59+ ]);
60+ const load = makeAssetsShellHtmlLoader({ assets: binding });
61+
62+ await expect(load()).rejects.toThrow(/different document/);
63+ });
64+
65+ it("prefers the dev document and never touches the binding when it is present", async () => {
66+ const { binding, requests } = bindingReturning([]);
67+ const load = makeAssetsShellHtmlLoader({
68+ assets: binding,
69+ devShellHtml: () => Promise.resolve(SHELL_HTML),
70+ });
71+
72+ expect(await load()).toBe(SHELL_HTML);
73+ expect(requests).toHaveLength(0);
74+ });
75+
76+ it("falls through to the binding when the dev document is undefined (a build)", async () => {
77+ const { binding } = bindingReturning([new Response(SHELL_HTML)]);
78+ const load = makeAssetsShellHtmlLoader({
79+ assets: binding,
80+ devShellHtml: () => Promise.resolve(undefined),
81+ });
82+
83+ expect(await load()).toBe(SHELL_HTML);
84+ });
85+});
packages/hosts/mcp-apps-shell/src/worker.ts+75−0
@@ -0,0 +1,75 @@
1+// The ambient declaration for `virtual:executor-mcp-apps-shell-dev-html`
2+// travels with this module into the Workers hosts that import it (their
3+// tsconfig `include` covers only their own sources) — same trick as
4+// `shell/artifact-renderer.tsx` and its `shell-html-url.d.ts`.
5+/// <reference path="./shell-dev-html.d.ts" />
6+
7+/**
8+ * The shell loader for Workers hosts, where `loadMcpAppsShellHtml`'s
9+ * `fs.readFile` can never succeed: a deployed isolate has no filesystem, so
10+ * the document arrives the way #1378 delivers the worker-bundler artifact —
11+ * emitted into the client assets at build time and fetched through the
12+ * `ASSETS` binding at runtime.
13+ *
14+ * Kept free of the shell's own module graph (React, Recharts, Tailwind): this
15+ * entry ships only a fetch and two constants, so a Worker can import it
16+ * without dragging browser code into its bundle.
17+ */
18+
19+import {
20+ MCP_APPS_SHELL_DOCUMENT_MARKER,
21+ MCP_APPS_SHELL_STABLE_ASSET_PATH,
22+} from "./shell-asset-path";
23+
24+/** The structural shape of a Workers static-assets binding (`env.ASSETS`). */
25+export interface ShellAssetsBinding {
26+ readonly fetch: (request: Request) => Promise<Response>;
27+}
28+
29+/**
30+ * Build a `loadAppShellHtml` over the `ASSETS` binding.
31+ *
32+ * `devShellHtml` carries the document inline during `vite dev`, where no
33+ * assets have been emitted yet: pass a thunk that dynamically imports
34+ * `virtual:executor-mcp-apps-shell-dev-html` — the built shell under `serve`,
35+ * `undefined` under `build`. A THUNK, not the value: under `serve` that
36+ * virtual module builds the multi-megabyte shell in a child process, a cost
37+ * only the first artifact resource read should pay, never dev-server
38+ * startup. Same dual-shape seam #1378 uses for the worker-bundler artifact.
39+ *
40+ * Failure REJECTS, deliberately, on either a non-OK response or a document
41+ * that is not the shell. The second check is load-bearing: under SPA
42+ * not-found handling the binding answers a miss with the console's
43+ * `index.html` and a 200, and serving THAT as the `ui://` resource is the
44+ * silent every-client-hangs failure this seam exists to kill — the client
45+ * loads a real document that never starts the MCP-Apps handshake. A rejected
46+ * read surfaces as a JSON-RPC error naming the missing asset instead.
47+ */
48+export const makeAssetsShellHtmlLoader = (input: {
49+ readonly assets: ShellAssetsBinding;
50+ readonly devShellHtml?: () => Promise<string | undefined>;
51+}): (() => Promise<string>) => {
52+ let cache: string | undefined;
53+ return async () => {
54+ if (cache !== undefined) return cache;
55+ const devHtml = await input.devShellHtml?.();
56+ if (devHtml !== undefined) {
57+ cache = devHtml;
58+ return cache;
59+ }
60+ const response = await input.assets.fetch(
61+ new Request(new URL(MCP_APPS_SHELL_STABLE_ASSET_PATH, "https://assets.local")),
62+ );
63+ const html = response.ok ? await response.text() : undefined;
64+ if (html === undefined || !html.includes(MCP_APPS_SHELL_DOCUMENT_MARKER)) {
65+ // 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
66+ throw new Error(
67+ `MCP-Apps shell asset ${MCP_APPS_SHELL_STABLE_ASSET_PATH} is missing from this deployment's ` +
68+ `static assets (${html === undefined ? `ASSETS fetch returned ${response.status}` : "the fetch returned a different document"}). ` +
69+ "The app build emits it via mcpAppsShellAsset (@executor-js/mcp-apps-shell/vite); rebuild and redeploy.",
70+ );
71+ }
72+ cache = html;
73+ return cache;
74+ };
75+};
packages/hosts/mcp-apps-shell/tsconfig.json+1−0
@@ -3,6 +3,7 @@ · 2 unmodified lines
33 "target": "ESNext",
44 "module": "ESNext",
55 "moduleResolution": "bundler",
6+ "allowImportingTsExtensions": true,
67 "strict": true,
78 "esModuleInterop": true,
89 "skipLibCheck": true,