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
119 "executor/no-try-catch-or-throw": "off",119 "executor/no-try-catch-or-throw": "off",
120 },120 },
121 },121 },
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 },
122 {147 {
123 "files": ["packages/kernel/core/src/code-recovery.ts"],148 "files": ["packages/kernel/core/src/code-recovery.ts"],
124 "rules": {149 "rules": {
apps/cli/src/build.ts+31−0
@@ -25,6 +25,32 @@ const resolveQuickJsWasmPath = (): string => { · 24 unmodified lines
25 return wasmPath;25 return wasmPath;
26};26};
27 27
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
40const mcpAppsShellDir = resolve(repoRoot, "packages/hosts/mcp-apps-shell");
41const mcpAppsShellHtml = join(mcpAppsShellDir, "dist/mcp-app.html");
42
43const 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
28const resolveOnePasswordCoreWasmPath = (): string => {54const resolveOnePasswordCoreWasmPath = (): string => {
29 const req = createRequire(join(repoRoot, "packages/plugins/onepassword/package.json"));55 const req = createRequire(join(repoRoot, "packages/plugins/onepassword/package.json"));
30 const sdkPkg = req.resolve("@1password/sdk/package.json");56 const sdkPkg = req.resolve("@1password/sdk/package.json");
@@ -398,6 +424,7 @@ const buildBinaries = async (targets: Target[], mode: BuildMode) => { · 367 unmodified lines
398 await writeFile(embeddedMigrationsPath, `${embeddedMigrations}\n`);424 await writeFile(embeddedMigrationsPath, `${embeddedMigrations}\n`);
399 425
400 const quickJsWasmPath = resolveQuickJsWasmPath();426 const quickJsWasmPath = resolveQuickJsWasmPath();
427 const mcpAppsShellPath = await ensureMcpAppsShell();
401 const onePasswordCoreWasmPath = resolveOnePasswordCoreWasmPath();428 const onePasswordCoreWasmPath = resolveOnePasswordCoreWasmPath();
402 const workerBundlerDistPath = resolveWorkerBundlerDistPath();429 const workerBundlerDistPath = resolveWorkerBundlerDistPath();
403 const packedWorkerBundlerSource = await createPackedWorkerBundlerSource(workerBundlerDistPath);430 const packedWorkerBundlerSource = await createPackedWorkerBundlerSource(workerBundlerDistPath);
@@ -423,6 +450,10 @@ const buildBinaries = async (targets: Target[], mode: BuildMode) => { · 19 unmodified lines
423 // Copy QuickJS WASM next to binary — loaded at runtime by the server450 // Copy QuickJS WASM next to binary — loaded at runtime by the server
424 await cp(quickJsWasmPath, join(binDir, "emscripten-module.wasm"));451 await cp(quickJsWasmPath, join(binDir, "emscripten-module.wasm"));
425 452
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
426 // Copy 1Password's sdk-core WASM next to the binary. The patched457 // Copy 1Password's sdk-core WASM next to the binary. The patched
427 // sdk-core loader falls back to this sibling file when bun --compile458 // sdk-core loader falls back to this sibling file when bun --compile
428 // bakes a build-machine node_modules path into __dirname.459 // bakes a build-machine node_modules path into __dirname.
e2e/mcp-apps/.gitignore+5−0
@@ -0,0 +1,5 @@
1node_modules/
2package-lock.json
3test-results/
4playwright-report/
5.exdata/
e2e/mcp-apps/README.md+67−0
@@ -0,0 +1,67 @@
1# MCP Apps tests (sunpeak)
2
3Render 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
5VM and no Claude/ChatGPT account**.
6
7[sunpeak](https://github.com/Sunpeak-AI/sunpeak) locally replicates the Claude
8and ChatGPT app runtimes: it connects to an MCP server, calls a UI tool, mounts
9the returned `ui://` resource in a sandboxed iframe with the host bridge, and
10hands the test a frame-scoped handle to the rendered component. Every test runs
11against both host simulations. This is the lightweight successor to the earlier
12MCPJam Playwright harness (which we drove by hand via fragile UI selectors).
13
14## Run
15
16```bash
17cd e2e/mcp-apps
18npm install # also patches sunpeak (see below) and is isolated from the bun workspace
19npm 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
23shell resource is served), so there is no daemon or HTTP token to manage. Specs
24live in `tests/`; `npm run test:headed` and `npm run report` help when debugging.
25
26A spec is tiny:
27
28```ts
29import { test, expect } from "sunpeak/test";
30test("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
391. **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
482. **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
58the shell too — see `apps/cli/src/build.ts` — but source is the cheaper path
59for a test.)
60
61## Scope
62
63sunpeak is a host _simulation_: it covers the protocol contract, the rendered
64UI, 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
66faster 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 @@
1import { fileURLToPath } from "node:url";
2import { dirname, resolve } from "node:path";
3import { 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.
15const here = dirname(fileURLToPath(import.meta.url));
16const repo = resolve(here, "../..");
17
18export 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.
15import { readFileSync, existsSync } from "node:fs";
16import { fileURLToPath } from "node:url";
17import { dirname, resolve } from "node:path";
18
19const here = dirname(fileURLToPath(import.meta.url));
20const target = resolve(here, "../node_modules/sunpeak/bin/commands/inspect.mjs");
21
22if (!existsSync(target)) {
23 console.error(`[check-sunpeak] ${target} not found. Is sunpeak installed?`);
24 process.exit(1);
25}
26
27const src = readFileSync(target, "utf8");
28const EXTENSION_ID = "io.modelcontextprotocol/ui";
29const MIME_TYPE = "text/html;profile=mcp-app";
30
31if (!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
45console.log("[check-sunpeak] inspector advertises MCP-Apps UI client capability");
e2e/mcp-apps/tests/render-ui.spec.ts+87−0
@@ -0,0 +1,87 @@
1import { 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.
6const appBody = (result: { app: () => ReturnType<ReturnType<typeof test>["app"]> } | any) =>
7 result.app().frameLocator("iframe");
8
9const 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
22test("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
38test("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.
53const structuredOf = (result: { structuredContent?: unknown }): Record<string, unknown> =>
54 (result.structuredContent ?? {}) as Record<string, unknown>;
55
56test("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
71test("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
28 getDescription: Effect.succeed("smoke"),28 getDescription: Effect.succeed("smoke"),
29};29};
30 30
31// oxlint-disable-next-line executor/no-double-cast -- boundary: MCP SDK ClientCapabilities predates the ext-apps `extensions` field
31const APPS_CAPS = {32const APPS_CAPS = {
32 extensions: { [EXTENSION_ID]: { mimeTypes: [RESOURCE_MIME_TYPE] } },33 extensions: { [EXTENSION_ID]: { mimeTypes: [RESOURCE_MIME_TYPE] } },
33} as unknown as ClientCapabilities;34} 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
123 123
124const SHELL_HTML = "<!doctype html><html><body><div id='root'></div></body></html>";124const SHELL_HTML = "<!doctype html><html><body><div id='root'></div></body></html>";
125 125
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
127const APPS_CAPS = {130const APPS_CAPS = {
128 extensions: { [EXTENSION_ID]: { mimeTypes: [RESOURCE_MIME_TYPE] } },131 extensions: { [EXTENSION_ID]: { mimeTypes: [RESOURCE_MIME_TYPE] } },
129} as unknown as ClientCapabilities;132} as unknown as ClientCapabilities;
@@ -217,7 +220,9 @@ describe("MCP host — artifact tool visibility", () => { · 87 unmodified lines
217 const read = await client.readResource({ uri: MCP_APPS_SHELL_RESOURCE_URI });220 const read = await client.readResource({ uri: MCP_APPS_SHELL_RESOURCE_URI });
218 const [content] = read.contents;221 const [content] = read.contents;
219 expect(content.mimeType).toBe(RESOURCE_MIME_TYPE);222 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);
221 // The shell may open no network connection of its own; everything226 // The shell may open no network connection of its own; everything
222 // routes back over the MCP bridge.227 // routes back over the MCP bridge.
223 expect(content._meta).toMatchObject({228 expect(content._meta).toMatchObject({