History

Ship the MCP-Apps shell in binaries and revive the sunpeak e2e harness

Rhys Sullivan committed 3 months ago

Commit 0bd7cdb1 parent 1b7bd7c11 files · +314 −2Browse files

Unified · Split

11 files
  1. .oxlintrc.jsonc +25 −0
  2. apps/cli/src/build.ts +31 −0
  3. e2e/mcp-apps/.gitignore +5 −0
  4. e2e/mcp-apps/README.md +67 −0
  5. e2e/mcp-apps/package.json +17 −0
  6. e2e/mcp-apps/playwright.config.ts +28 −0
  7. e2e/mcp-apps/scripts/check-sunpeak.mjs +45 −0
  8. e2e/mcp-apps/tests/render-ui.spec.ts +87 −0
  9. packages/hosts/mcp-apps-shell/CHANGELOG.md +1 −0
  10. packages/hosts/mcp-apps-shell/src/shell-resource.smoke.test.ts +1 −0
  11. packages/hosts/mcp/src/artifacts-tools.test.ts +7 −2
.oxlintrc.jsonc+25−0
@@ -119,6 +119,31 @@ · 118 unmodified lines
119119 "executor/no-try-catch-or-throw": "off",
120120 },
121121 },
122+ {
123+ // boundary: the MCP-Apps shell is browser code, not Effect domain code.
124+ // It runs inside a sandboxed iframe and talks to the host over
125+ // postMessage and the MCP bridge — both of which are throw/Promise APIs
126+ // with no Effect runtime present. `new Function` evaluation of generated
127+ // JSX, CSS harvesting off `document.styleSheets`, and JSON on the wire
128+ // all surface untyped JS errors that must be caught and rendered rather
129+ // than modelled. Scoped to the iframe payload plus its build config; the
130+ // package's Effect-facing loader (`src/shell-html.ts`) is deliberately
131+ // NOT listed and uses inline suppressions instead.
132+ "files": [
133+ "packages/hosts/mcp-apps-shell/src/shell/**/*.{ts,tsx}",
134+ "packages/hosts/mcp-apps-shell/vite.config.shell.ts",
135+ ],
136+ "rules": {
137+ "executor/no-double-cast": "off",
138+ "executor/no-error-constructor": "off",
139+ "executor/no-instanceof-error": "off",
140+ "executor/no-json-parse": "off",
141+ "executor/no-promise-catch": "off",
142+ "executor/no-promise-reject": "off",
143+ "executor/no-try-catch-or-throw": "off",
144+ "executor/no-unknown-error-message": "off",
145+ },
146+ },
122147 {
123148 "files": ["packages/kernel/core/src/code-recovery.ts"],
124149 "rules": {
apps/cli/src/build.ts+31−0
@@ -25,6 +25,32 @@ const resolveQuickJsWasmPath = (): string => { · 24 unmodified lines
2525 return wasmPath;
2626 };
2727
28+// ---------------------------------------------------------------------------
29+// MCP-Apps shell (mcp-app.html)
30+//
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
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
35+// generated UI would render blank. Build it if missing and copy it next to the
36+// executable, where the loader finds it via `process.execPath` — the same
37+// colocation trick used for `libsql.node` / `emscripten-module.wasm`.
38+// ---------------------------------------------------------------------------
39+
40+const mcpAppsShellDir = resolve(repoRoot, "packages/hosts/mcp-apps-shell");
41+const mcpAppsShellHtml = join(mcpAppsShellDir, "dist/mcp-app.html");
42+
43+const ensureMcpAppsShell = async (): Promise<string> => {
44+ if (!existsSync(mcpAppsShellHtml)) {
45+ console.log("Building MCP-Apps shell (mcp-app.html)...");
46+ await $`bun run --cwd ${mcpAppsShellDir} build:shell`.quiet();
47+ }
48+ if (!existsSync(mcpAppsShellHtml)) {
49+ throw new Error(`MCP-Apps shell missing at ${mcpAppsShellHtml} after build:shell`);
50+ }
51+ return mcpAppsShellHtml;
52+};
53+
2854 const resolveOnePasswordCoreWasmPath = (): string => {
2955 const req = createRequire(join(repoRoot, "packages/plugins/onepassword/package.json"));
3056 const sdkPkg = req.resolve("@1password/sdk/package.json");
@@ -398,6 +424,7 @@ const buildBinaries = async (targets: Target[], mode: BuildMode) => { · 367 unmodified lines
398424 await writeFile(embeddedMigrationsPath, `${embeddedMigrations}\n`);
399425
400426 const quickJsWasmPath = resolveQuickJsWasmPath();
427+ const mcpAppsShellPath = await ensureMcpAppsShell();
401428 const onePasswordCoreWasmPath = resolveOnePasswordCoreWasmPath();
402429 const workerBundlerDistPath = resolveWorkerBundlerDistPath();
403430 const packedWorkerBundlerSource = await createPackedWorkerBundlerSource(workerBundlerDistPath);
@@ -423,6 +450,10 @@ const buildBinaries = async (targets: Target[], mode: BuildMode) => { · 19 unmodified lines
423450 // Copy QuickJS WASM next to binary — loaded at runtime by the server
424451 await cp(quickJsWasmPath, join(binDir, "emscripten-module.wasm"));
425452
453+ // Copy the MCP-Apps shell next to the binary. Platform-independent HTML,
454+ // read from execDir at runtime by @executor-js/mcp-apps-shell.
455+ await cp(mcpAppsShellPath, join(binDir, "mcp-app.html"));
456+
426457 // Copy 1Password's sdk-core WASM next to the binary. The patched
427458 // sdk-core loader falls back to this sibling file when bun --compile
428459 // bakes a build-machine node_modules path into __dirname.
e2e/mcp-apps/.gitignore+5−0
@@ -0,0 +1,5 @@
1+node_modules/
2+package-lock.json
3+test-results/
4+playwright-report/
5+.exdata/
e2e/mcp-apps/README.md+67−0
@@ -0,0 +1,67 @@
1+# MCP Apps tests (sunpeak)
2+
3+Render and test executor's generative-UI MCP Apps (the `render-ui` tool from the
4+`mcp-apps-shell` plugin) in a **real MCP-Apps host**, headlessly, in CI, with **no
5+VM and no Claude/ChatGPT account**.
6+
7+[sunpeak](https://github.com/Sunpeak-AI/sunpeak) locally replicates the Claude
8+and ChatGPT app runtimes: it connects to an MCP server, calls a UI tool, mounts
9+the returned `ui://` resource in a sandboxed iframe with the host bridge, and
10+hands the test a frame-scoped handle to the rendered component. Every test runs
11+against both host simulations. This is the lightweight successor to the earlier
12+MCPJam Playwright harness (which we drove by hand via fragile UI selectors).
13+
14+## Run
15+
16+```bash
17+cd e2e/mcp-apps
18+npm install # also patches sunpeak (see below) and is isolated from the bun workspace
19+npm test # builds the shell, starts sunpeak + `executor mcp`, runs the specs
20+```
21+
22+`playwright.config.ts` starts `executor mcp` over **stdio** (from source, so the
23+shell resource is served), so there is no daemon or HTTP token to manage. Specs
24+live in `tests/`; `npm run test:headed` and `npm run report` help when debugging.
25+
26+A spec is tiny:
27+
28+```ts
29+import { test, expect } from "sunpeak/test";
30+test("widget mounts", async ({ inspector }) => {
31+ const result = await inspector.renderTool("render-ui", { code: APP_SRC });
32+ const app = result.app().frameLocator("iframe"); // see "extra iframe" below
33+ await expect(app.locator('button:has-text("Increment")')).toBeVisible();
34+});
35+```
36+
37+## Two interop notes (both handled here)
38+
39+1. **sunpeak doesn't advertise the MCP-Apps UI _client_ capability.** Its
40+ inspector connects with `new Client({ name, version })` and never declares
41+ `capabilities.extensions["io.modelcontextprotocol/ui"]`. executor's
42+ `render-ui` (correctly, per the MCP-Apps spec) only mounts the widget inline
43+ when the host advertises it can render `text/html;profile=mcp-app`; otherwise
44+ it returns its browser **fallback URL** and nothing renders. `scripts/patch-sunpeak.mjs`
45+ (a postinstall) adds that capability to sunpeak's client. This is
46+ upstream-worthy: sunpeak should advertise it itself.
47+
48+2. **executor's shell nests one extra iframe.** The shell mounts the generated
49+ component in a nested `srcdoc` iframe (`shell-app` -> `inner-renderer`, the
50+ double-iframe sandbox), one level below sunpeak's `result.app()`. So tests use
51+ `result.app().frameLocator("iframe")` to reach the component.
52+
53+## Why stdio + from source
54+
55+`render-ui`'s shell resource (`ui://executor/shell-tanstack-query.html`) is
56+`packages/hosts/mcp-apps-shell/dist/mcp-app.html`. `pretest` builds it. Running
57+`executor mcp` from source serves it directly. (The compiled binary now ships
58+the shell too — see `apps/cli/src/build.ts` — but source is the cheaper path
59+for a test.)
60+
61+## Scope
62+
63+sunpeak is a host _simulation_: it covers the protocol contract, the rendered
64+UI, and tool/theme/display-mode behavior. It does not catch real-host quirks
65+(OAuth, production CSP). For the shell component in isolation there is also the
66+faster in-repo unit test at
67+`packages/hosts/mcp-apps-shell/src/shell/mcp-app.browser.test.ts`.
e2e/mcp-apps/package.json+17−0
@@ -0,0 +1,17 @@
1+{
2+ "name": "@executor-js/e2e-mcp-apps",
3+ "private": true,
4+ "description": "Render + test executor's generative-UI MCP Apps in a real MCP-Apps host (sunpeak), headless, no VM.",
5+ "type": "module",
6+ "scripts": {
7+ "postinstall": "node scripts/check-sunpeak.mjs",
8+ "pretest": "bun run --cwd ../../packages/hosts/mcp-apps-shell build:shell",
9+ "test": "playwright test",
10+ "test:headed": "playwright test --headed",
11+ "report": "playwright show-report"
12+ },
13+ "devDependencies": {
14+ "@playwright/test": "^1.56.0",
15+ "sunpeak": "^0.20.56"
16+ }
17+}
e2e/mcp-apps/playwright.config.ts+28−0
@@ -0,0 +1,28 @@
1+import { fileURLToPath } from "node:url";
2+import { dirname, resolve } from "node:path";
3+import { defineConfig } from "sunpeak/test/config";
4+
5+// sunpeak runs `sunpeak inspect` (a real MCP-Apps host that mounts ui://
6+// resources in a sandboxed iframe) as the Playwright web server, connects it to
7+// the MCP server below, and gives tests a frame-scoped handle to the rendered
8+// app. We point it at executor over stdio (`executor mcp`) so the whole thing is
9+// self-contained: no daemon to manage, no HTTP token.
10+//
11+// Run the daemon FROM SOURCE (bun apps/cli/bin/executor.ts) so the shell
12+// resource is served from packages/hosts/mcp-apps-shell/dist/mcp-app.html
13+// (`pretest` builds it). The compiled binary also ships the shell, but source
14+// is the cheapest path for a test.
15+const here = dirname(fileURLToPath(import.meta.url));
16+const repo = resolve(here, "../..");
17+
18+export default defineConfig({
19+ testDir: "tests",
20+ server: {
21+ command: "bun",
22+ args: [resolve(repo, "apps/cli/bin/executor.ts"), "mcp"],
23+ env: {
24+ EXECUTOR_DATA_DIR: resolve(here, ".exdata"),
25+ },
26+ cwd: repo,
27+ },
28+});
e2e/mcp-apps/scripts/check-sunpeak.mjs+45−0
@@ -0,0 +1,45 @@
1+// Verify sunpeak's inspector advertises the MCP-Apps UI *client* capability to
2+// the upstream MCP server.
3+//
4+// executor gates inline UI rendering on that advertisement (per the MCP-Apps
5+// spec: a server mounts inline only when the host says it can render
6+// `text/html;profile=mcp-app`). A host that doesn't advertise it gets the
7+// deep-link fallback instead of the widget — which would make this whole suite
8+// pass while testing the wrong delivery path.
9+//
10+// sunpeak used to construct its Client with no capabilities at all and we
11+// patched the shipped file. As of 0.20.69 it declares the capability itself, so
12+// this is now a CHECK rather than a patch. It stays fatal: if a future sunpeak
13+// drops the capability, the harness must fail loudly at install time rather
14+// than silently degrade.
15+import { readFileSync, existsSync } from "node:fs";
16+import { fileURLToPath } from "node:url";
17+import { dirname, resolve } from "node:path";
18+
19+const here = dirname(fileURLToPath(import.meta.url));
20+const target = resolve(here, "../node_modules/sunpeak/bin/commands/inspect.mjs");
21+
22+if (!existsSync(target)) {
23+ console.error(`[check-sunpeak] ${target} not found. Is sunpeak installed?`);
24+ process.exit(1);
25+}
26+
27+const src = readFileSync(target, "utf8");
28+const EXTENSION_ID = "io.modelcontextprotocol/ui";
29+const MIME_TYPE = "text/html;profile=mcp-app";
30+
31+if (!src.includes(EXTENSION_ID) || !src.includes(MIME_TYPE)) {
32+ console.error(
33+ [
34+ "[check-sunpeak] the inspector no longer advertises MCP-Apps client support.",
35+ ` expected both ${EXTENSION_ID} and ${MIME_TYPE}`,
36+ ` in: ${target}`,
37+ "Without it executor returns its deep-link fallback and these tests would pass",
38+ "while testing nothing. Pin a sunpeak version that advertises the capability,",
39+ "or restore a patch here that injects it into the Client constructor.",
40+ ].join("\n"),
41+ );
42+ process.exit(1);
43+}
44+
45+console.log("[check-sunpeak] inspector advertises MCP-Apps UI client capability");
e2e/mcp-apps/tests/render-ui.spec.ts+87−0
@@ -0,0 +1,87 @@
1+import { test, expect } from "sunpeak/test";
2+
3+// executor's render-ui shell mounts the generated component in a *nested* srcdoc
4+// iframe (shell-app -> inner-renderer, the MCP-Apps double-iframe sandbox), one
5+// level below sunpeak's own `result.app()`. Descend that extra level.
6+const appBody = (result: { app: () => ReturnType<ReturnType<typeof test>["app"]> } | any) =>
7+ result.app().frameLocator("iframe");
8+
9+const COUNTER = `function App() {
10+ const [count, setCount] = useState(0);
11+ return (
12+ <div className="p-4">
13+ <h2>MCP App counter</h2>
14+ <Badge>{count}</Badge>
15+ <Button onClick={() => setCount((c) => c + 1)}>Increment</Button>
16+ </div>
17+ );
18+}`;
19+
20+// sunpeak runs every test against both the Claude and ChatGPT host simulations.
21+
22+test("render-ui mounts an interactive React widget", async ({ inspector }) => {
23+ const result = await inspector.renderTool("render-ui", {
24+ code: COUNTER,
25+ title: "MCP App counter",
26+ });
27+ const app = appBody(result);
28+
29+ await expect(app.locator("text=MCP App counter")).toBeVisible({ timeout: 15000 });
30+ const inc = app.locator('button:has-text("Increment")');
31+ await expect(inc).toBeVisible();
32+
33+ // state is live: clicking updates the rendered output
34+ await inc.click();
35+ await expect(app.locator("text=1")).toBeVisible();
36+});
37+
38+test("render-ui renders in dark theme", async ({ inspector }) => {
39+ const result = await inspector.renderTool(
40+ "render-ui",
41+ { code: COUNTER, title: "MCP App counter" },
42+ { theme: "dark" },
43+ );
44+ await expect(appBody(result).locator("text=MCP App counter")).toBeVisible({ timeout: 15000 });
45+});
46+
47+// The whole point of patching sunpeak to advertise MCP-Apps support is that
48+// executor returns the inline widget rather than its deep-link fallback. Assert
49+// that directly, so a future sunpeak change that drops the capability fails
50+// here instead of quietly testing the wrong delivery path.
51+// Protocol-level assertions go through the `mcp` fixture: `inspector` models
52+// what a *host* shows the user and does not surface the raw tool result.
53+const structuredOf = (result: { structuredContent?: unknown }): Record<string, unknown> =>
54+ (result.structuredContent ?? {}) as Record<string, unknown>;
55+
56+test("render-ui returns an inline widget, not the deep-link fallback", async ({ mcp }) => {
57+ const result = await mcp.callTool("render-ui", {
58+ code: COUNTER,
59+ title: "MCP App counter",
60+ description: "A counter used by the MCP Apps e2e harness",
61+ });
62+ const structured = structuredOf(result);
63+ // The whole point of the inspector advertising MCP-Apps support: executor
64+ // must hand back the component, not a URL to go look at it elsewhere.
65+ expect(structured.status).not.toBe("fallback_url");
66+ expect(structured.code).toContain("MCP App counter");
67+ // Every successful render is persisted, so the artifact can be reopened.
68+ expect(structured.artifactId).toBeTruthy();
69+});
70+
71+test("a rendered artifact is listed and can be shown again", async ({ inspector, mcp }) => {
72+ const rendered = await mcp.callTool("render-ui", {
73+ code: COUNTER,
74+ title: "Retrievable counter",
75+ description: "Saved so list-artifacts can find it",
76+ });
77+ const artifactId = structuredOf(rendered).artifactId as string;
78+ expect(artifactId).toBeTruthy();
79+
80+ const listed = await mcp.callTool("list-artifacts", {});
81+ const artifacts = structuredOf(listed).artifacts as Array<{ id: string }> | undefined;
82+ expect(artifacts?.some((artifact) => artifact.id === artifactId)).toBe(true);
83+
84+ // …and the stored source really does render, through the same shell.
85+ const shown = await inspector.renderTool("show-artifact", { id: artifactId });
86+ await expect(appBody(shown).locator("text=MCP App counter")).toBeVisible({ timeout: 15000 });
87+});
packages/hosts/mcp-apps-shell/CHANGELOG.md+1−0
@@ -0,0 +1 @@
1+# @executor-js/mcp-apps-shell
packages/hosts/mcp-apps-shell/src/shell-resource.smoke.test.ts+1−0
@@ -28,6 +28,7 @@ const stubEngine: ExecutionEngine<never> = { · 27 unmodified lines
2828 getDescription: Effect.succeed("smoke"),
2929 };
3030
31+// oxlint-disable-next-line executor/no-double-cast -- boundary: MCP SDK ClientCapabilities predates the ext-apps `extensions` field
3132 const APPS_CAPS = {
3233 extensions: { [EXTENSION_ID]: { mimeTypes: [RESOURCE_MIME_TYPE] } },
3334 } as unknown as ClientCapabilities;
packages/hosts/mcp/src/artifacts-tools.test.ts+7−2
@@ -123,7 +123,10 @@ const withClient = async <E extends Cause.YieldableError>( · 122 unmodified lines
123123
124124 const SHELL_HTML = "<!doctype html><html><body><div id='root'></div></body></html>";
125125
126−// What a client that renders MCP Apps advertises at `initialize`.
126+// What a client that renders MCP Apps advertises at `initialize`. The SDK's
127+// `ClientCapabilities` has no `extensions` field yet (pending SEP-1724), which
128+// is exactly why ext-apps ships `getUiCapability` to read it.
129+// oxlint-disable-next-line executor/no-double-cast -- boundary: MCP SDK ClientCapabilities predates the ext-apps `extensions` field
127130 const APPS_CAPS = {
128131 extensions: { [EXTENSION_ID]: { mimeTypes: [RESOURCE_MIME_TYPE] } },
129132 } as unknown as ClientCapabilities;
@@ -217,7 +220,9 @@ describe("MCP host — artifact tool visibility", () => { · 87 unmodified lines
217220 const read = await client.readResource({ uri: MCP_APPS_SHELL_RESOURCE_URI });
218221 const [content] = read.contents;
219222 expect(content.mimeType).toBe(RESOURCE_MIME_TYPE);
220− expect(content.text).toBe(SHELL_HTML);
223+ // Served as text, never as a blob.
224+ expect("text" in content).toBe(true);
225+ expect("text" in content ? content.text : "").toBe(SHELL_HTML);
221226 // The shell may open no network connection of its own; everything
222227 // routes back over the MCP bridge.
223228 expect(content._meta).toMatchObject({