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
7The 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
31// The MCP host serves the shell as the `ui://executor/shell.html` resource, and31// The MCP host serves the shell as the `ui://executor/shell.html` resource, and
32// `@executor-js/mcp-apps-shell` reads it from disk at runtime. That runtime32// `@executor-js/mcp-apps-shell` reads it from disk at runtime. That runtime
33// `fs.readFile` is invisible to `bun build --compile`, so without this the33// `fs.readFile` is invisible to `bun build --compile`, so without this the
34// packaged binary would serve the "Shell not built" placeholder and every34// packaged binary would fail every artifact resource read. Build it if missing
35// generated UI would render blank. Build it if missing and copy it next to the35// and copy it next to the executable, where the loader finds it via
36// executable, where the loader finds it via `process.execPath` — the same36// `process.execPath` — the same colocation trick used for `libsql.node` /
37// colocation trick used for `libsql.node` / `emscripten-module.wasm`.37// `emscripten-module.wasm`.
38// ---------------------------------------------------------------------------38// ---------------------------------------------------------------------------
39 39
40const mcpAppsShellDir = resolve(repoRoot, "packages/hosts/mcp-apps-shell");40const mcpAppsShellDir = resolve(repoRoot, "packages/hosts/mcp-apps-shell");
apps/cloud/scripts/build.mjs+20−1
@@ -9,7 +9,7 @@ · 8 unmodified lines
9 9
10import { spawnSync } from "node:child_process";10import { spawnSync } from "node:child_process";
11import { randomBytes } from "node:crypto";11import { randomBytes } from "node:crypto";
12import { rmSync } from "node:fs";12import { existsSync, readFileSync, rmSync } from "node:fs";
13 13
14if (!process.env.VITE_PUBLIC_ANALYTICS_PATH) {14if (!process.env.VITE_PUBLIC_ANALYTICS_PATH) {
15 process.env.VITE_PUBLIC_ANALYTICS_PATH = randomBytes(4).toString("hex");15 process.env.VITE_PUBLIC_ANALYTICS_PATH = randomBytes(4).toString("hex");
@@ -32,3 +32,22 @@ for (const step of steps) { · 16 unmodified lines
32 process.exit(result.status ?? 1);32 process.exit(result.status ?? 1);
33 }33 }
34}34}
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.
42const shellAsset = new URL(
43 "../dist/client/assets/executor-mcp-apps-shell-stable.html",
44 import.meta.url,
45);
46if (!existsSync(shellAsset)) {
47 console.error(`[build] missing MCP-Apps shell asset at ${shellAsset.pathname}`);
48 process.exit(1);
49}
50if (!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
26} from "@executor-js/host-mcp/tool-server";26} from "@executor-js/host-mcp/tool-server";
27import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval";27import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval";
28import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact";28import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact";
29import { loadMcpAppsShellHtml } from "@executor-js/mcp-apps-shell";29import { makeAssetsShellHtmlLoader } from "@executor-js/mcp-apps-shell/worker";
30import { smokeRenderArtifact } from "@executor-js/mcp-apps-shell/smoke-render";30import { smokeRenderArtifact } from "@executor-js/mcp-apps-shell/smoke-render";
31import {31import {
32 McpAgentSessionDOBase,32 McpAgentSessionDOBase,
@@ -156,6 +156,18 @@ const makeSessionServices = (dbHandle: CloudSessionDbHandle) => { · 123 unmodified lines
156 return Layer.mergeAll(DbLive, UserStoreLive, CoreSharedServices);156 return Layer.mergeAll(DbLive, UserStoreLive, CoreSharedServices);
157};157};
158 158
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.
165const loadAppShellHtml = makeAssetsShellHtmlLoader({
166 assets: env.ASSETS,
167 devShellHtml: () =>
168 import("virtual:executor-mcp-apps-shell-dev-html").then((mod) => mod.devShellHtml),
169});
170
159// ---------------------------------------------------------------------------171// ---------------------------------------------------------------------------
160// Durable Object172// Durable Object
161// ---------------------------------------------------------------------------173// ---------------------------------------------------------------------------
@@ -268,7 +280,7 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 106 unmodified lines
268 // flag existed carry no value; absent now means off, same as a fresh280 // flag existed carry no value; absent now means off, same as a fresh
269 // connection that did not ask for `?artifacts=true`.281 // connection that did not ask for `?artifacts=true`.
270 artifactsEnabled: sessionMeta.artifactsEnabled ?? false,282 artifactsEnabled: sessionMeta.artifactsEnabled ?? false,
271 loadAppShellHtml: loadMcpAppsShellHtml,283 loadAppShellHtml,
272 smokeRenderArtifact,284 smokeRenderArtifact,
273 artifactUrl: artifactUrlFor(285 artifactUrl: artifactUrlFor(
274 env.VITE_PUBLIC_SITE_URL ?? "https://executor.sh",286 env.VITE_PUBLIC_SITE_URL ?? "https://executor.sh",
apps/host-cloudflare/package.json+2−2
@@ -3,8 +3,8 @@ · 2 unmodified lines
3 "private": true,3 "private": true,
4 "type": "module",4 "type": "module",
5 "scripts": {5 "scripts": {
6 "build": "vite build",6 "build": "vite build && node scripts/assert-shell-asset.mjs",
7 "deploy": "vite build && wrangler deploy",7 "deploy": "vite build && node scripts/assert-shell-asset.mjs && wrangler deploy",
8 "dev": "wrangler dev",8 "dev": "wrangler dev",
9 "dev:web": "vite dev",9 "dev:web": "vite dev",
10 "typecheck": "tsgo --noEmit",10 "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.
7import { existsSync, readFileSync } from "node:fs";
8
9const shellAsset = new URL("../dist/assets/executor-mcp-apps-shell-stable.html", import.meta.url);
10if (!existsSync(shellAsset)) {
11 console.error(`[build] missing MCP-Apps shell asset at ${shellAsset.pathname}`);
12 process.exit(1);
13}
14if (!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
21 readonly DB: D1Database;21 readonly DB: D1Database;
22 /** R2 bucket binding — holds values too large for a D1 row (~1-2MB cap). */22 /** R2 bucket binding — holds values too large for a D1 row (~1-2MB cap). */
23 readonly BLOBS?: R2Bucket;23 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> };
24 /** MCP session Durable Object namespace — one addressable isolate per MCP28 /** MCP session Durable Object namespace — one addressable isolate per MCP
25 * session (the DO id IS the session id), so a session survives across the29 * session (the DO id IS the session id), so a session survives across the
26 * Worker's stateless isolates. */30 * Worker's stateless isolates. */
@@ -74,7 +78,7 @@ export interface CloudflareConfig { · 47 unmodified lines
74 78
75type CloudflareConfigEnv = Omit<79type CloudflareConfigEnv = Omit<
76 CloudflareEnv,80 CloudflareEnv,
77 "DB" | "BLOBS" | "MCP_SESSION" | "MCP_EXECUTION_OWNER"81 "DB" | "BLOBS" | "ASSETS" | "MCP_SESSION" | "MCP_EXECUTION_OWNER"
78>;82>;
79 83
80type CloudflareAccessEnv = Pick<84type CloudflareAccessEnv = Pick<
apps/host-cloudflare/src/mcp/session-durable-object.ts+11−2
@@ -6,7 +6,7 @@ import { · 5 unmodified lines
6} from "@executor-js/host-mcp/tool-server";6} from "@executor-js/host-mcp/tool-server";
7import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval";7import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval";
8import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact";8import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact";
9import { loadMcpAppsShellHtml } from "@executor-js/mcp-apps-shell";9import { makeAssetsShellHtmlLoader } from "@executor-js/mcp-apps-shell/worker";
10import { smokeRenderArtifact } from "@executor-js/mcp-apps-shell/smoke-render";10import { smokeRenderArtifact } from "@executor-js/mcp-apps-shell/smoke-render";
11import type { ExecutorDbHandle } from "@executor-js/api/server";11import type { ExecutorDbHandle } from "@executor-js/api/server";
12import {12import {
@@ -57,6 +57,14 @@ class McpModelResumeForwardError extends Data.TaggedError("McpModelResumeForward · 44 unmodified lines
57export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSessionDbHandle> {57export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSessionDbHandle> {
58 private readonly cfEnv: CloudflareEnv;58 private readonly cfEnv: CloudflareEnv;
59 private readonly cfConfig: CloudflareConfig;59 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>;
60 68
61 constructor(69 constructor(
62 ctx: ConstructorParameters<typeof McpAgentSessionDOBase<CloudflareEnv, CfSessionDbHandle>>[0],70 ctx: ConstructorParameters<typeof McpAgentSessionDOBase<CloudflareEnv, CfSessionDbHandle>>[0],
@@ -65,6 +73,7 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession · 2 unmodified lines
65 super(ctx, env);73 super(ctx, env);
66 this.cfEnv = env;74 this.cfEnv = env;
67 this.cfConfig = loadConfig(env);75 this.cfConfig = loadConfig(env);
76 this.loadAppShellHtml = makeAssetsShellHtmlLoader({ assets: env.ASSETS });
68 }77 }
69 78
70 protected override executionOwnerDirectory(): McpExecutionOwnerDirectory | null {79 protected override executionOwnerDirectory(): McpExecutionOwnerDirectory | null {
@@ -140,7 +149,7 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession · 69 unmodified lines
140 // flag existed carry no value; absent now means off, same as a fresh149 // flag existed carry no value; absent now means off, same as a fresh
141 // connection that did not ask for `?artifacts=true`.150 // connection that did not ask for `?artifacts=true`.
142 artifactsEnabled: sessionMeta.artifactsEnabled ?? false,151 artifactsEnabled: sessionMeta.artifactsEnabled ?? false,
143 loadAppShellHtml: loadMcpAppsShellHtml,152 loadAppShellHtml: self.loadAppShellHtml,
144 smokeRenderArtifact,153 smokeRenderArtifact,
145 ...(artifactOrigin154 ...(artifactOrigin
146 ? { artifactUrl: artifactUrlFor(artifactOrigin, sessionMeta.organizationSlug) }155 ? { artifactUrl: artifactUrlFor(artifactOrigin, sessionMeta.organizationSlug) }
apps/host-cloudflare/wrangler.jsonc+4−0
@@ -15,6 +15,10 @@ · 14 unmodified lines
15 // Worker instead of the SPA fallback.15 // Worker instead of the SPA fallback.
16 "assets": {16 "assets": {
17 "directory": "./dist",17 "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",
18 "not_found_handling": "single-page-application",22 "not_found_handling": "single-page-application",
19 "run_worker_first": ["/api/*", "/mcp", "/mcp/*", "/.well-known/*", "/v1", "/v1/*"],23 "run_worker_first": ["/api/*", "/mcp", "/mcp/*", "/.well-known/*", "/v1", "/v1/*"],
20 },24 },
apps/host-selfhost/package.json+1−1
@@ -10,7 +10,7 @@ · 9 unmodified lines
10 },10 },
11 "scripts": {11 "scripts": {
12 "dev": "bunx --bun vite dev",12 "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",
14 "start": "bun run src/serve.ts",14 "start": "bun run src/serve.ts",
15 "typecheck": "tsgo --noEmit",15 "typecheck": "tsgo --noEmit",
16 "test": "vitest run",16 "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.
8import { existsSync, readFileSync } from "node:fs";
9
10const shellAsset = new URL("../dist/assets/executor-mcp-apps-shell-stable.html", import.meta.url);
11if (!existsSync(shellAsset)) {
12 console.error(`[build] missing MCP-Apps shell asset at ${shellAsset.pathname}`);
13 process.exit(1);
14}
15if (!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
12 "types": "./src/shell-html.ts",12 "types": "./src/shell-html.ts",
13 "default": "./src/shell-html.ts"13 "default": "./src/shell-html.ts"
14 },14 },
15 "./worker": {
16 "types": "./src/worker.ts",
17 "default": "./src/worker.ts"
18 },
15 "./smoke-render": {19 "./smoke-render": {
16 "types": "./src/smoke-render-entry.ts",20 "types": "./src/smoke-render-entry.ts",
17 "default": "./src/smoke-render-entry.ts"21 "default": "./src/smoke-render-entry.ts"
packages/hosts/mcp-apps-shell/src/index.ts+6−4
@@ -2,10 +2,12 @@ · 1 unmodified line
2 * The MCP-Apps shell: the trusted iframe host that renders model-written JSX.2 * The MCP-Apps shell: the trusted iframe host that renders model-written JSX.
3 *3 *
4 * The shell reaches MCP clients as a single self-contained HTML document (the4 * The shell reaches MCP clients as a single self-contained HTML document (the
5 * `ui://executor/shell.html` resource). Hosts read it with5 * `ui://executor/shell.html` resource), handed to the MCP host through the
6 * `loadMcpAppsShellHtml` and hand it to the MCP host through the
7 * `loadAppShellHtml` config seam — the MCP host never imports this package6 * `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`).
9 */11 */
10 12
11export { loadMcpAppsShellHtml, MCP_APPS_SHELL_NOT_BUILT_HTML } from "./shell-html";13export { 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 */
22export 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 */
35export 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 */
10declare 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 @@
1import { MCP_APPS_SHELL_STABLE_ASSET_PATH } from "./shell-asset-path";
2
1let shellHtmlCache: string | undefined;3let shellHtmlCache: string | undefined;
2 4
3/**5/**
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.
5 *8 *
6 * Hosts inject this through `SharedMcpServerConfig.loadAppShellHtml` rather9 * Hosts inject this through `SharedMcpServerConfig.loadAppShellHtml` rather
7 * than the MCP host package importing it directly: the shell drags React,10 * than the MCP host package importing it directly: the shell drags React,
8 * Recharts and Tailwind into whatever graph imports it, and the MCP host also11 * Recharts and Tailwind into whatever graph imports it, and the MCP host also
9 * runs on Workers where none of that belongs.12 * 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.
10 */28 */
11export const loadMcpAppsShellHtml = async (): Promise<string> => {29export const loadMcpAppsShellHtml = async (): Promise<string> => {
12 if (shellHtmlCache) return shellHtmlCache;30 if (shellHtmlCache) return shellHtmlCache;
13 31
14 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: optional prebuilt shell asset is loaded from local filesystem when present32 const fs = await import("node:fs/promises");
15 try {33 const path = await import("node:path");
16 const fs = await import("node:fs/promises");34 const candidates = [
17 const path = await import("node:path");35 // `bun build --compile` can't bundle this runtime `fs.readFile`, so the
18 const candidates = [36 // binary build (apps/cli/src/build.ts) copies `mcp-app.html` next to the
19 // `bun build --compile` can't bundle this runtime `fs.readFile`, so the37 // executable. We find it via `process.execPath`, the same colocation
20 // binary build (apps/cli/src/build.ts) copies `mcp-app.html` next to the38 // trick native-bindings.ts uses for `libsql.node` / `keyring.node`.
21 // executable. We find it via `process.execPath`, the same colocation39 path.join(path.dirname(process.execPath), "mcp-app.html"),
22 // trick native-bindings.ts uses for `libsql.node` / `keyring.node`.40 // Dev / package-resolved (`bun run`, vitest): the package's own dist.
23 path.join(path.dirname(process.execPath), "mcp-app.html"),41 path.join(import.meta.dirname, "../dist/mcp-app.html"),
24 // Dev / package-resolved (`bun run`, vitest): the package's own dist.42 path.join(import.meta.dirname, "../../dist/mcp-app.html"),
25 path.join(import.meta.dirname, "../dist/mcp-app.html"),43 // The self-host runtime image. Its server is bundled to
26 path.join(import.meta.dirname, "../../dist/mcp-app.html"),44 // `apps/host-selfhost/dist-server/serve.js`, and the sibling `dist/` is
27 ];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 ];
28 50
29 for (const candidate of candidates) {51 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 back52 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: try each possible emitted shell path before failing
31 try {53 try {
32 shellHtmlCache = await fs.readFile(candidate, "utf-8");54 shellHtmlCache = await fs.readFile(candidate, "utf-8");
33 return shellHtmlCache;55 return shellHtmlCache;
34 } catch {56 } catch {
35 // Try the next candidate path.57 // Try the next candidate path.
36 }
37 }58 }
38 } catch {
39 // Fall through to the development fallback below.
40 }59 }
41 60
42 shellHtmlCache = MCP_APPS_SHELL_NOT_BUILT_HTML;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
43 return shellHtmlCache;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 );
44};67};
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. */
48export 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
8import { MCP_APPS_SHELL_RESOURCE_URI } from "@executor-js/host-mcp/create-artifact";8import { MCP_APPS_SHELL_RESOURCE_URI } from "@executor-js/host-mcp/create-artifact";
9import type { ExecutionEngine } from "@executor-js/execution";9import type { ExecutionEngine } from "@executor-js/execution";
10 10
11import { loadMcpAppsShellHtml, MCP_APPS_SHELL_NOT_BUILT_HTML } from "./shell-html";11import { loadMcpAppsShellHtml } from "./shell-html";
12import { MCP_APPS_SHELL_DOCUMENT_MARKER } from "./shell-asset-path";
12 13
13// The shipped binary serves the shell from a file on disk (`build:shell` output,14// The shipped binary serves the shell from a file on disk (`build:shell` output,
14// copied next to the executable by `apps/cli/src/build.ts`). If that file is15// 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 UI16// missing the loader rejects the resource read — it used to degrade to a
16// renders blank — so assert an MCP client reading the resource gets the REAL17// placeholder document, which hung every MCP-Apps client silently — so assert
17// built shell, not the placeholder. This replaces a build-time subprocess probe18// an MCP client reading the resource gets the REAL built shell. This replaces
18// that hard-slept 7 seconds inside every binary build.19// a build-time subprocess probe that hard-slept 7 seconds inside every binary
20// build.
19 21
20const stubEngine: ExecutionEngine<never> = {22const stubEngine: ExecutionEngine<never> = {
21 execute: () => Effect.succeed({ result: "ok" }),23 execute: () => Effect.succeed({ result: "ok" }),
@@ -34,7 +36,7 @@ const APPS_CAPS = { · 12 unmodified lines
34} as unknown as ClientCapabilities;36} as unknown as ClientCapabilities;
35 37
36describe("MCP-Apps shell resource", () => {38describe("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 () => {
38 const mcpServer = await Effect.runPromise(40 const mcpServer = await Effect.runPromise(
39 // Artifacts are opt-in per connection; the shell resource only exists on41 // Artifacts are opt-in per connection; the shell resource only exists on
40 // a session that asked for them.42 // a session that asked for them.
@@ -58,9 +60,12 @@ describe("MCP-Apps shell resource", () => { · 17 unmodified lines
58 // The shell is served as text, never as a blob.60 // The shell is served as text, never as a blob.
59 expect("text" in content).toBe(true);61 expect("text" in content).toBe(true);
60 const html = "text" in content ? content.text : "";62 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");
63 expect(html).toContain('id="root"');63 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);
64 // `vite-plugin-singlefile` inlines every asset: a shell that still points at69 // `vite-plugin-singlefile` inlines every asset: a shell that still points at
65 // a sibling bundle would 404 inside the host's sandboxed iframe.70 // a sibling bundle would 404 inside the host's sandboxed iframe.
66 //71 //
packages/hosts/mcp-apps-shell/src/shell/mcp-app.html+5−0
@@ -3,6 +3,11 @@ · 2 unmodified lines
3 <head>3 <head>
4 <meta charset="UTF-8" />4 <meta charset="UTF-8" />
5 <meta name="viewport" content="width=device-width, initial-scale=1.0" />5 <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" />
6 <title>Executor Shell</title>11 <title>Executor Shell</title>
7 </head>12 </head>
8 <body>13 <body>
packages/hosts/mcp-apps-shell/src/vite.test.ts+11−0
@@ -14,4 +14,15 @@ describe("mcpAppsShellAsset", () => { · 13 unmodified lines
14 "export default shellHtmlUrl;\n",14 "export default shellHtmlUrl;\n",
15 );15 );
16 });16 });
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 });
17});28});
packages/hosts/mcp-apps-shell/src/vite.ts+49−1
@@ -25,6 +25,11 @@ import { createHash } from "node:crypto"; · 24 unmodified lines
25import path from "node:path";25import path from "node:path";
26import { fileURLToPath } from "node:url";26import { fileURLToPath } from "node:url";
27 27
28import {
29 MCP_APPS_SHELL_DOCUMENT_MARKER,
30 MCP_APPS_SHELL_STABLE_ASSET_PATH,
31} from "./shell-asset-path.ts";
32
28/** The structural shape of the Vite plugin object, declared locally so this33/** The structural shape of the Vite plugin object, declared locally so this
29 * module imports no Vite types (apps pin their own Vite version). */34 * module imports no Vite types (apps pin their own Vite version). */
30export interface InnerRendererVitePlugin {35export interface InnerRendererVitePlugin {
@@ -172,6 +177,12 @@ export const tailwindBrowserSourcePlugin = (): InnerRendererVitePlugin => ({ · 141 unmodified lines
172const SHELL_VIRTUAL_ID = "virtual:executor-mcp-apps-shell-html";177const SHELL_VIRTUAL_ID = "virtual:executor-mcp-apps-shell-html";
173const SHELL_RESOLVED_ID = `\0${SHELL_VIRTUAL_ID}`;178const SHELL_RESOLVED_ID = `\0${SHELL_VIRTUAL_ID}`;
174const DEV_SHELL_URL = "/assets/executor-mcp-apps-shell-dev.html";179const 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.
184const DEV_HTML_VIRTUAL_ID = "virtual:executor-mcp-apps-shell-dev-html";
185const DEV_HTML_RESOLVED_ID = `\0${DEV_HTML_VIRTUAL_ID}`;
175 186
176/** Where `build:shell` writes the self-contained shell document. */187/** Where `build:shell` writes the self-contained shell document. */
177export const mcpAppsShellHtmlPath = (): string => path.resolve(packageRoot, "dist/mcp-app.html");188export const mcpAppsShellHtmlPath = (): string => path.resolve(packageRoot, "dist/mcp-app.html");
@@ -283,6 +294,17 @@ export const mcpAppsShellAsset = (): McpAppsShellAssetVitePlugin => { · 105 unmodified lines
283 const load = async (): Promise<{ readonly html: string; readonly url: string }> => {294 const load = async (): Promise<{ readonly html: string; readonly url: string }> => {
284 if (cached) return cached;295 if (cached) return cached;
285 const html = await buildMcpAppsShellHtml();296 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 }
286 const digest = createHash("sha256").update(html).digest("hex").slice(0, 12);308 const digest = createHash("sha256").update(html).digest("hex").slice(0, 12);
287 cached = { html, url: `/assets/executor-mcp-apps-shell-${digest}.html` };309 cached = { html, url: `/assets/executor-mcp-apps-shell-${digest}.html` };
288 return cached;310 return cached;
@@ -300,8 +322,24 @@ export const mcpAppsShellAsset = (): McpAppsShellAssetVitePlugin => { · 11 unmodified lines
300 // only carries the single-environment case, where the two agree.322 // only carries the single-environment case, where the two agree.
301 ssr = Boolean(config.build?.ssr);323 ssr = Boolean(config.build?.ssr);
302 },324 },
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 },
304 load: async (id) => {330 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 }
305 if (id !== SHELL_RESOLVED_ID) return undefined;343 if (id !== SHELL_RESOLVED_ID) return undefined;
306 // Resolving the virtual module is part of Vite's startup graph. Building344 // Resolving the virtual module is part of Vite's startup graph. Building
307 // the multi-megabyte shell here makes every dev server pay that cost345 // the multi-megabyte shell here makes every dev server pay that cost
@@ -355,6 +393,16 @@ export const mcpAppsShellAsset = (): McpAppsShellAssetVitePlugin => { · 47 unmodified lines
355 // configured — which `process.cwd()`-relative writes get wrong for393 // configured — which `process.cwd()`-relative writes get wrong for
356 // TanStack Start, where the client build writes to `dist/client`.394 // TanStack Start, where the client build writes to `dist/client`.
357 this.emitFile({ type: "asset", fileName: url.replace(/^\//, ""), source: html });395 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 });
358 },406 },
359 async closeBundle() {407 async closeBundle() {
360 cached = undefined;408 cached = undefined;
packages/hosts/mcp-apps-shell/src/worker.test.ts+85−0
@@ -0,0 +1,85 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import { makeAssetsShellHtmlLoader, type ShellAssetsBinding } from "./worker";
4import {
5 MCP_APPS_SHELL_DOCUMENT_MARKER,
6 MCP_APPS_SHELL_STABLE_ASSET_PATH,
7} from "./shell-asset-path";
8
9const SHELL_HTML = `<!doctype html><html><head><meta ${MCP_APPS_SHELL_DOCUMENT_MARKER} content="shell"></head><body><div id="root"></div></body></html>`;
10
11const 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
34describe("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
19import {
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`). */
25export 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 */
48export 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
3 "target": "ESNext",3 "target": "ESNext",
4 "module": "ESNext",4 "module": "ESNext",
5 "moduleResolution": "bundler",5 "moduleResolution": "bundler",
6 "allowImportingTsExtensions": true,
6 "strict": true,7 "strict": true,
7 "esModuleInterop": true,8 "esModuleInterop": true,
8 "skipLibCheck": true,9 "skipLibCheck": true,