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).
Unified · Split
| @@ -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. | ||
| @@ -31,10 +31,10 @@ const resolveQuickJsWasmPath = (): string => { · 30 unmodified lines | |||
| 31 | // The MCP host serves the shell as the `ui://executor/shell.html` resource, and | 31 | // 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 runtime | 32 | // `@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 the | 33 | // `fs.readFile` is invisible to `bun build --compile`, so without this the |
| 34 | // packaged binary would serve the "Shell not built" placeholder and every | 34 | // 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 the | 35 | // and copy it next to the executable, where the loader finds it via |
| 36 | // executable, where the loader finds it via `process.execPath` — the same | 36 | // `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 | ||
| 40 | const mcpAppsShellDir = resolve(repoRoot, "packages/hosts/mcp-apps-shell"); | 40 | const mcpAppsShellDir = resolve(repoRoot, "packages/hosts/mcp-apps-shell"); |
| @@ -9,7 +9,7 @@ · 8 unmodified lines | |||
| 9 | 9 | ||
| 10 | import { spawnSync } from "node:child_process"; | 10 | import { spawnSync } from "node:child_process"; |
| 11 | import { randomBytes } from "node:crypto"; | 11 | import { randomBytes } from "node:crypto"; |
| 12 | import { rmSync } from "node:fs"; | 12 | import { existsSync, readFileSync, rmSync } from "node:fs"; |
| 13 | 13 | ||
| 14 | if (!process.env.VITE_PUBLIC_ANALYTICS_PATH) { | 14 | if (!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. | ||
| 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 | } | ||
| @@ -26,7 +26,7 @@ import { · 25 unmodified lines | |||
| 26 | } from "@executor-js/host-mcp/tool-server"; | 26 | } from "@executor-js/host-mcp/tool-server"; |
| 27 | import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval"; | 27 | import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval"; |
| 28 | import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact"; | 28 | 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"; |
| 30 | import { smokeRenderArtifact } from "@executor-js/mcp-apps-shell/smoke-render"; | 30 | import { smokeRenderArtifact } from "@executor-js/mcp-apps-shell/smoke-render"; |
| 31 | import { | 31 | import { |
| 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. | ||
| 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 | |||
| 159 | // --------------------------------------------------------------------------- | 171 | // --------------------------------------------------------------------------- |
| 160 | // Durable Object | 172 | // 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 fresh | 280 | // 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", |
| @@ -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", |
| @@ -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 | } | ||
| @@ -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 MCP | 28 | /** MCP session Durable Object namespace — one addressable isolate per MCP |
| 25 | * session (the DO id IS the session id), so a session survives across the | 29 | * 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 | ||
| 75 | type CloudflareConfigEnv = Omit< | 79 | type 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 | ||
| 80 | type CloudflareAccessEnv = Pick< | 84 | type CloudflareAccessEnv = Pick< |
| @@ -6,7 +6,7 @@ import { · 5 unmodified lines | |||
| 6 | } from "@executor-js/host-mcp/tool-server"; | 6 | } from "@executor-js/host-mcp/tool-server"; |
| 7 | import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval"; | 7 | import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval"; |
| 8 | import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact"; | 8 | 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"; |
| 10 | import { smokeRenderArtifact } from "@executor-js/mcp-apps-shell/smoke-render"; | 10 | import { smokeRenderArtifact } from "@executor-js/mcp-apps-shell/smoke-render"; |
| 11 | import type { ExecutorDbHandle } from "@executor-js/api/server"; | 11 | import type { ExecutorDbHandle } from "@executor-js/api/server"; |
| 12 | import { | 12 | import { |
| @@ -57,6 +57,14 @@ class McpModelResumeForwardError extends Data.TaggedError("McpModelResumeForward · 44 unmodified lines | |||
| 57 | export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSessionDbHandle> { | 57 | export 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 fresh | 149 | // 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 | ...(artifactOrigin | 154 | ...(artifactOrigin |
| 146 | ? { artifactUrl: artifactUrlFor(artifactOrigin, sessionMeta.organizationSlug) } | 155 | ? { artifactUrl: artifactUrlFor(artifactOrigin, sessionMeta.organizationSlug) } |
| @@ -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 | }, |
| @@ -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", |
| @@ -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 | } | ||
| @@ -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" |
| @@ -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 (the | 4 | * The shell reaches MCP clients as a single self-contained HTML document (the |
| 5 | * `ui://executor/shell.html` resource). Hosts read it with | 5 | * `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 package | 6 | * `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 | ||
| 11 | export { loadMcpAppsShellHtml, MCP_APPS_SHELL_NOT_BUILT_HTML } from "./shell-html"; | 13 | export { loadMcpAppsShellHtml } from "./shell-html"; |
| @@ -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"'; | ||
| @@ -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 | } | ||
| @@ -1,49 +1,67 @@ | |||
| 1 | import { MCP_APPS_SHELL_STABLE_ASSET_PATH } from "./shell-asset-path"; | ||
| 2 | |||
| 1 | let shellHtmlCache: string | undefined; | 3 | let 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` rather | 9 | * 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 also | 11 | * 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 | */ |
| 11 | export const loadMcpAppsShellHtml = async (): Promise<string> => { | 29 | export 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 present | 32 | 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 the | 37 | // executable. We find it via `process.execPath`, the same colocation |
| 20 | // binary build (apps/cli/src/build.ts) copies `mcp-app.html` next to the | 38 | // trick native-bindings.ts uses for `libsql.node` / `keyring.node`. |
| 21 | // executable. We find it via `process.execPath`, the same colocation | 39 | 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 back | 52 | // 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. */ | ||
| 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>"; | ||
| @@ -8,14 +8,16 @@ import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server"; · 7 unmodified lines | |||
| 8 | import { MCP_APPS_SHELL_RESOURCE_URI } from "@executor-js/host-mcp/create-artifact"; | 8 | import { MCP_APPS_SHELL_RESOURCE_URI } from "@executor-js/host-mcp/create-artifact"; |
| 9 | import type { ExecutionEngine } from "@executor-js/execution"; | 9 | import type { ExecutionEngine } from "@executor-js/execution"; |
| 10 | 10 | ||
| 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"; | ||
| 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 is | 15 | // 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 | // 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 REAL | 17 | // placeholder document, which hung every MCP-Apps client silently — so assert |
| 17 | // built shell, not the placeholder. This replaces a build-time subprocess probe | 18 | // 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 | ||
| 20 | const stubEngine: ExecutionEngine<never> = { | 22 | const 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 | ||
| 36 | describe("MCP-Apps shell resource", () => { | 38 | 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 () => { |
| 38 | const mcpServer = await Effect.runPromise( | 40 | const mcpServer = await Effect.runPromise( |
| 39 | // Artifacts are opt-in per connection; the shell resource only exists on | 41 | // 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 at | 69 | // `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 | // |
| @@ -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> |
| @@ -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 | }); |
| @@ -25,6 +25,11 @@ import { createHash } from "node:crypto"; · 24 unmodified lines | |||
| 25 | import path from "node:path"; | 25 | import path from "node:path"; |
| 26 | import { fileURLToPath } from "node:url"; | 26 | import { fileURLToPath } from "node:url"; |
| 27 | 27 | ||
| 28 | import { | ||
| 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 this | 33 | /** 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). */ |
| 30 | export interface InnerRendererVitePlugin { | 35 | export interface InnerRendererVitePlugin { |
| @@ -172,6 +177,12 @@ export const tailwindBrowserSourcePlugin = (): InnerRendererVitePlugin => ({ · 141 unmodified lines | |||
| 172 | const SHELL_VIRTUAL_ID = "virtual:executor-mcp-apps-shell-html"; | 177 | const SHELL_VIRTUAL_ID = "virtual:executor-mcp-apps-shell-html"; |
| 173 | const SHELL_RESOLVED_ID = `\0${SHELL_VIRTUAL_ID}`; | 178 | const SHELL_RESOLVED_ID = `\0${SHELL_VIRTUAL_ID}`; |
| 174 | const DEV_SHELL_URL = "/assets/executor-mcp-apps-shell-dev.html"; | 179 | 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}`; | ||
| 175 | 186 | ||
| 176 | /** Where `build:shell` writes the self-contained shell document. */ | 187 | /** Where `build:shell` writes the self-contained shell document. */ |
| 177 | export const mcpAppsShellHtmlPath = (): string => path.resolve(packageRoot, "dist/mcp-app.html"); | 188 | export 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. Building | 344 | // Resolving the virtual module is part of Vite's startup graph. Building |
| 307 | // the multi-megabyte shell here makes every dev server pay that cost | 345 | // 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 for | 393 | // 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; |
| @@ -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 | }); | ||
| @@ -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 | }; | ||
| @@ -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, |