Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/violet-rules-hang.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"executor": patch
---

**Fix: MCP clients of the cloud host got a "Shell not built" placeholder as the `ui://executor/shell.html` resource, so every artifact rendered as a widget that never finished loading**

The deployed Worker has no filesystem, and the shell loader silently fell back to an inert placeholder document when its `fs.readFile` failed. Workers hosts now fetch the built shell through the static-assets binding (the app build emits a stable-named copy alongside the hashed one), the self-host image reads the same emitted asset from its SPA dist, and a host that cannot produce the shell now fails the resource read with an actionable error instead of serving a document that hangs the client. App builds fail if the shell asset was not emitted.
8 changes: 4 additions & 4 deletions apps/cli/src/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,10 @@ const resolveQuickJsWasmPath = (): string => {
// The MCP host serves the shell as the `ui://executor/shell.html` resource, and
// `@executor-js/mcp-apps-shell` reads it from disk at runtime. That runtime
// `fs.readFile` is invisible to `bun build --compile`, so without this the
// packaged binary would serve the "Shell not built" placeholder and every
// generated UI would render blank. Build it if missing and copy it next to the
// executable, where the loader finds it via `process.execPath` — the same
// colocation trick used for `libsql.node` / `emscripten-module.wasm`.
// packaged binary would fail every artifact resource read. Build it if missing
// and copy it next to the executable, where the loader finds it via
// `process.execPath` — the same colocation trick used for `libsql.node` /
// `emscripten-module.wasm`.
// ---------------------------------------------------------------------------

const mcpAppsShellDir = resolve(repoRoot, "packages/hosts/mcp-apps-shell");
Expand Down
21 changes: 20 additions & 1 deletion apps/cloud/scripts/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

import { spawnSync } from "node:child_process";
import { randomBytes } from "node:crypto";
import { rmSync } from "node:fs";
import { existsSync, readFileSync, rmSync } from "node:fs";

if (!process.env.VITE_PUBLIC_ANALYTICS_PATH) {
process.env.VITE_PUBLIC_ANALYTICS_PATH = randomBytes(4).toString("hex");
Expand All @@ -32,3 +32,22 @@ for (const step of steps) {
process.exit(result.status ?? 1);
}
}

// The deployed Worker serves the MCP-Apps shell (`ui://executor/shell.html`)
// by fetching this stable-named asset through the ASSETS binding — it has no
// filesystem to read the shell from. A build that failed to emit it would
// deploy a Worker whose artifact resource reads all fail, so make the miss a
// build failure here instead of a production incident. Same shape as the
// worker-bundler asset assertion from #1378.
const shellAsset = new URL(
"../dist/client/assets/executor-mcp-apps-shell-stable.html",
import.meta.url,
);
if (!existsSync(shellAsset)) {
console.error(`[build] missing MCP-Apps shell asset at ${shellAsset.pathname}`);
process.exit(1);
}
if (!readFileSync(shellAsset, "utf8").includes('name="executor-mcp-apps-shell"')) {
console.error(`[build] ${shellAsset.pathname} is not the MCP-Apps shell document`);
process.exit(1);
}
16 changes: 14 additions & 2 deletions apps/cloud/src/mcp/session-durable-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import {
} from "@executor-js/host-mcp/tool-server";
import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval";
import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact";
import { loadMcpAppsShellHtml } from "@executor-js/mcp-apps-shell";
import { makeAssetsShellHtmlLoader } from "@executor-js/mcp-apps-shell/worker";
import { smokeRenderArtifact } from "@executor-js/mcp-apps-shell/smoke-render";
import {
McpAgentSessionDOBase,
Expand Down Expand Up @@ -156,6 +156,18 @@ const makeSessionServices = (dbHandle: CloudSessionDbHandle) => {
return Layer.mergeAll(DbLive, UserStoreLive, CoreSharedServices);
};

// The `ui://executor/shell.html` resource, over the ASSETS binding: the
// deployed Worker has no filesystem, so the document is the stable-named
// asset the client build emitted (`mcpAppsShellAsset`), fetched at first
// artifact resource read. Module scope so the fetch-and-verify happens once
// per isolate, not once per session. The dev thunk carries the built shell
// inline under `vite dev`, where no assets exist yet for the binding to find.
const loadAppShellHtml = makeAssetsShellHtmlLoader({
assets: env.ASSETS,
devShellHtml: () =>
import("virtual:executor-mcp-apps-shell-dev-html").then((mod) => mod.devShellHtml),
});

// ---------------------------------------------------------------------------
// Durable Object
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -268,7 +280,7 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD
// flag existed carry no value; absent now means off, same as a fresh
// connection that did not ask for `?artifacts=true`.
artifactsEnabled: sessionMeta.artifactsEnabled ?? false,
loadAppShellHtml: loadMcpAppsShellHtml,
loadAppShellHtml,
smokeRenderArtifact,
artifactUrl: artifactUrlFor(
env.VITE_PUBLIC_SITE_URL ?? "https://executor.sh",
Expand Down
4 changes: 2 additions & 2 deletions apps/host-cloudflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
"private": true,
"type": "module",
"scripts": {
"build": "vite build",
"deploy": "vite build && wrangler deploy",
"build": "vite build && node scripts/assert-shell-asset.mjs",
"deploy": "vite build && node scripts/assert-shell-asset.mjs && wrangler deploy",
"dev": "wrangler dev",
"dev:web": "vite dev",
"typecheck": "tsgo --noEmit",
Expand Down
17 changes: 17 additions & 0 deletions apps/host-cloudflare/scripts/assert-shell-asset.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Build-blocking check that the SPA build emitted the stable-named MCP-Apps
// shell document. The MCP session DO serves `ui://executor/shell.html` by
// fetching this asset through the ASSETS binding — a deployed Worker has no
// filesystem — so a build without it ships a Worker whose artifact resource
// reads all fail. Fail the build here instead. Same shape as apps/cloud's
// assertion in scripts/build.mjs.
import { existsSync, readFileSync } from "node:fs";

const shellAsset = new URL("../dist/assets/executor-mcp-apps-shell-stable.html", import.meta.url);
if (!existsSync(shellAsset)) {
console.error(`[build] missing MCP-Apps shell asset at ${shellAsset.pathname}`);
process.exit(1);
}
if (!readFileSync(shellAsset, "utf8").includes('name="executor-mcp-apps-shell"')) {
console.error(`[build] ${shellAsset.pathname} is not the MCP-Apps shell document`);
process.exit(1);
}
6 changes: 5 additions & 1 deletion apps/host-cloudflare/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ export interface CloudflareEnv {
readonly DB: D1Database;
/** R2 bucket binding — holds values too large for a D1 row (~1-2MB cap). */
readonly BLOBS?: R2Bucket;
/** Static assets binding (wrangler.jsonc `assets.binding`). The MCP session
* DO fetches the built MCP-Apps shell document through it — a deployed
* Worker has no filesystem to read the shell from. */
readonly ASSETS: { readonly fetch: (request: Request) => Promise<Response> };
/** MCP session Durable Object namespace — one addressable isolate per MCP
* session (the DO id IS the session id), so a session survives across the
* Worker's stateless isolates. */
Expand Down Expand Up @@ -74,7 +78,7 @@ export interface CloudflareConfig {

type CloudflareConfigEnv = Omit<
CloudflareEnv,
"DB" | "BLOBS" | "MCP_SESSION" | "MCP_EXECUTION_OWNER"
"DB" | "BLOBS" | "ASSETS" | "MCP_SESSION" | "MCP_EXECUTION_OWNER"
>;

type CloudflareAccessEnv = Pick<
Expand Down
13 changes: 11 additions & 2 deletions apps/host-cloudflare/src/mcp/session-durable-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
} from "@executor-js/host-mcp/tool-server";
import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval";
import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact";
import { loadMcpAppsShellHtml } from "@executor-js/mcp-apps-shell";
import { makeAssetsShellHtmlLoader } from "@executor-js/mcp-apps-shell/worker";
import { smokeRenderArtifact } from "@executor-js/mcp-apps-shell/smoke-render";
import type { ExecutorDbHandle } from "@executor-js/api/server";
import {
Expand Down Expand Up @@ -57,6 +57,14 @@ class McpModelResumeForwardError extends Data.TaggedError("McpModelResumeForward
export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSessionDbHandle> {
private readonly cfEnv: CloudflareEnv;
private readonly cfConfig: CloudflareConfig;
/**
* The `ui://executor/shell.html` document, over the ASSETS binding: a
* deployed Worker has no filesystem, so the shell is the stable-named asset
* the SPA build emitted into `./dist` (`mcpAppsShellAsset`). No dev-mode
* escape hatch here, unlike cloud: this Worker is bundled by wrangler, not
* Vite, and `wrangler dev` serves the same built `./dist` the binding reads.
*/
private readonly loadAppShellHtml: () => Promise<string>;

constructor(
ctx: ConstructorParameters<typeof McpAgentSessionDOBase<CloudflareEnv, CfSessionDbHandle>>[0],
Expand All @@ -65,6 +73,7 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession
super(ctx, env);
this.cfEnv = env;
this.cfConfig = loadConfig(env);
this.loadAppShellHtml = makeAssetsShellHtmlLoader({ assets: env.ASSETS });
}

protected override executionOwnerDirectory(): McpExecutionOwnerDirectory | null {
Expand Down Expand Up @@ -140,7 +149,7 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession
// flag existed carry no value; absent now means off, same as a fresh
// connection that did not ask for `?artifacts=true`.
artifactsEnabled: sessionMeta.artifactsEnabled ?? false,
loadAppShellHtml: loadMcpAppsShellHtml,
loadAppShellHtml: self.loadAppShellHtml,
smokeRenderArtifact,
...(artifactOrigin
? { artifactUrl: artifactUrlFor(artifactOrigin, sessionMeta.organizationSlug) }
Expand Down
4 changes: 4 additions & 0 deletions apps/host-cloudflare/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
// Worker instead of the SPA fallback.
"assets": {
"directory": "./dist",
// The MCP session DO reads the built MCP-Apps shell document through this
// binding (`ui://executor/shell.html` has no filesystem to come from on a
// deployed Worker).
"binding": "ASSETS",
"not_found_handling": "single-page-application",
"run_worker_first": ["/api/*", "/mcp", "/mcp/*", "/.well-known/*", "/v1", "/v1/*"],
},
Expand Down
2 changes: 1 addition & 1 deletion apps/host-selfhost/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
},
"scripts": {
"dev": "bunx --bun vite dev",
"build": "turbo run build --filter @executor-js/vite-plugin && vite build",
"build": "turbo run build --filter @executor-js/vite-plugin && vite build && node scripts/assert-shell-asset.mjs",
"start": "bun run src/serve.ts",
"typecheck": "tsgo --noEmit",
"test": "vitest run",
Expand Down
18 changes: 18 additions & 0 deletions apps/host-selfhost/scripts/assert-shell-asset.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Build-blocking check that the SPA build emitted the stable-named MCP-Apps
// shell document. The self-host server serves `ui://executor/shell.html` by
// reading this file from the SPA dist (`loadMcpAppsShellHtml`'s image-layout
// candidate) — the runtime image carries no `packages/` tree for the package
// dist candidates to hit — so a build without it ships an image whose
// artifact resource reads all fail. Fail the build here instead. Same shape
// as apps/cloud's assertion in scripts/build.mjs.
import { existsSync, readFileSync } from "node:fs";

const shellAsset = new URL("../dist/assets/executor-mcp-apps-shell-stable.html", import.meta.url);
if (!existsSync(shellAsset)) {
console.error(`[build] missing MCP-Apps shell asset at ${shellAsset.pathname}`);
process.exit(1);
}
if (!readFileSync(shellAsset, "utf8").includes('name="executor-mcp-apps-shell"')) {
console.error(`[build] ${shellAsset.pathname} is not the MCP-Apps shell document`);
process.exit(1);
}
4 changes: 4 additions & 0 deletions packages/hosts/mcp-apps-shell/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@
"types": "./src/shell-html.ts",
"default": "./src/shell-html.ts"
},
"./worker": {
"types": "./src/worker.ts",
"default": "./src/worker.ts"
},
"./smoke-render": {
"types": "./src/smoke-render-entry.ts",
"default": "./src/smoke-render-entry.ts"
Expand Down
10 changes: 6 additions & 4 deletions packages/hosts/mcp-apps-shell/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@
* The MCP-Apps shell: the trusted iframe host that renders model-written JSX.
*
* The shell reaches MCP clients as a single self-contained HTML document (the
* `ui://executor/shell.html` resource). Hosts read it with
* `loadMcpAppsShellHtml` and hand it to the MCP host through the
* `ui://executor/shell.html` resource), handed to the MCP host through the
* `loadAppShellHtml` config seam — the MCP host never imports this package
* directly, so React/Recharts/Tailwind stay out of the Workers graph.
* directly, so React/Recharts/Tailwind stay out of the Workers graph. Hosts
* with a filesystem read it with `loadMcpAppsShellHtml`; Workers hosts fetch
* it through their ASSETS binding with `makeAssetsShellHtmlLoader`
* (`@executor-js/mcp-apps-shell/worker`).
*/

export { loadMcpAppsShellHtml, MCP_APPS_SHELL_NOT_BUILT_HTML } from "./shell-html";
export { loadMcpAppsShellHtml } from "./shell-html";
35 changes: 35 additions & 0 deletions packages/hosts/mcp-apps-shell/src/shell-asset-path.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* The names the built shell document travels under, shared by the build-time
* emitter (`./vite`) and the runtime loaders (`./shell-html`, `./worker`).
*
* In its own module so runtime loaders can import the constants without
* dragging in the build-time plugin machinery (esbuild, child-process Vite).
*/

/**
* Where `mcpAppsShellAsset` emits the STABLE-NAMED copy of the built shell
* document, alongside the content-hashed one.
*
* The hashed name exists for browsers: the console's artifact page loads the
* shell by URL, and a content hash is what lets that URL be cached forever. A
* Worker serving the `ui://executor/shell.html` MCP resource has the opposite
* problem — it fetches the document server-side through the ASSETS binding,
* at runtime, from a bundle that cannot know the hash the client build
* produced. A second copy at a name both sides agree on ahead of time is the
* seam. No staleness risk: the binding serves the current deployment's
* assets, not a browser cache keyed on the URL.
*/
export const MCP_APPS_SHELL_STABLE_ASSET_PATH = "/assets/executor-mcp-apps-shell-stable.html";

/**
* A fragment every built shell document contains (the `<meta>` in
* `src/shell/mcp-app.html`), and no other document does.
*
* Exists because a fetch through a Workers ASSETS binding cannot be trusted
* by status alone: with `not_found_handling: "single-page-application"`
* (host-cloudflare), a miss returns the console's `index.html` with a 200.
* Serving THAT as the shell resource would be the exact silent-hang failure
* this seam exists to kill — the client loads a real document that never
* speaks the MCP-Apps protocol. Loaders verify the marker and fail loudly.
*/
export const MCP_APPS_SHELL_DOCUMENT_MARKER = 'name="executor-mcp-apps-shell"';
14 changes: 14 additions & 0 deletions packages/hosts/mcp-apps-shell/src/shell-dev-html.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* The built shell document's BYTES under `vite dev`, or `undefined` in a
* build — supplied by `mcpAppsShellAsset` (`@executor-js/mcp-apps-shell/vite`).
*
* Only Workers hosts import this, as the `devShellHtml` thunk for
* `makeAssetsShellHtmlLoader`: in dev no assets have been emitted for the
* ASSETS binding to find, so the document travels inline; in a deployed
* build this module is `undefined` and the binding is the real path.
*/
declare module "virtual:executor-mcp-apps-shell-dev-html" {
const devShellHtml: string | undefined;
export { devShellHtml };
export default devShellHtml;
}
Loading
Loading