Unified · Split
| @@ -119,6 +119,31 @@ · 118 unmodified lines | ||
| 119 | 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 | 148 | "files": ["packages/kernel/core/src/code-recovery.ts"], |
| 124 | 149 | "rules": { |
| @@ -25,6 +25,32 @@ const resolveQuickJsWasmPath = (): string => { · 24 unmodified lines | ||
| 25 | 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 | + | |
| 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 | + | |
| 28 | 54 | const resolveOnePasswordCoreWasmPath = (): string => { |
| 29 | 55 | const req = createRequire(join(repoRoot, "packages/plugins/onepassword/package.json")); |
| 30 | 56 | const sdkPkg = req.resolve("@1password/sdk/package.json"); |
| @@ -398,6 +424,7 @@ const buildBinaries = async (targets: Target[], mode: BuildMode) => { · 367 unmodified lines | ||
| 398 | 424 | await writeFile(embeddedMigrationsPath, `${embeddedMigrations}\n`); |
| 399 | 425 | |
| 400 | 426 | const quickJsWasmPath = resolveQuickJsWasmPath(); |
| 427 | + const mcpAppsShellPath = await ensureMcpAppsShell(); | |
| 401 | 428 | const onePasswordCoreWasmPath = resolveOnePasswordCoreWasmPath(); |
| 402 | 429 | const workerBundlerDistPath = resolveWorkerBundlerDistPath(); |
| 403 | 430 | const packedWorkerBundlerSource = await createPackedWorkerBundlerSource(workerBundlerDistPath); |
| @@ -423,6 +450,10 @@ const buildBinaries = async (targets: Target[], mode: BuildMode) => { · 19 unmodified lines | ||
| 423 | 450 | // Copy QuickJS WASM next to binary — loaded at runtime by the server |
| 424 | 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 | 457 | // Copy 1Password's sdk-core WASM next to the binary. The patched |
| 427 | 458 | // sdk-core loader falls back to this sibling file when bun --compile |
| 428 | 459 | // bakes a build-machine node_modules path into __dirname. |
| @@ -0,0 +1,5 @@ | ||
| 1 | +node_modules/ | |
| 2 | +package-lock.json | |
| 3 | +test-results/ | |
| 4 | +playwright-report/ | |
| 5 | +.exdata/ | |
| @@ -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`. | |
| @@ -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 | +} | |
| @@ -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 | +}); | |
| @@ -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"); | |
| @@ -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 | +}); | |
| @@ -0,0 +1 @@ | ||
| 1 | +# @executor-js/mcp-apps-shell | |
| @@ -28,6 +28,7 @@ const stubEngine: ExecutionEngine<never> = { · 27 unmodified lines | ||
| 28 | 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 | |
| 31 | 32 | const APPS_CAPS = { |
| 32 | 33 | extensions: { [EXTENSION_ID]: { mimeTypes: [RESOURCE_MIME_TYPE] } }, |
| 33 | 34 | } as unknown as ClientCapabilities; |
| @@ -123,7 +123,10 @@ const withClient = async <E extends Cause.YieldableError>( · 122 unmodified lines | ||
| 123 | 123 | |
| 124 | 124 | const 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 | |
| 127 | 130 | const APPS_CAPS = { |
| 128 | 131 | extensions: { [EXTENSION_ID]: { mimeTypes: [RESOURCE_MIME_TYPE] } }, |
| 129 | 132 | } as unknown as ClientCapabilities; |
| @@ -217,7 +220,9 @@ describe("MCP host — artifact tool visibility", () => { · 87 unmodified lines | ||
| 217 | 220 | const read = await client.readResource({ uri: MCP_APPS_SHELL_RESOURCE_URI }); |
| 218 | 221 | const [content] = read.contents; |
| 219 | 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 | 226 | // The shell may open no network connection of its own; everything |
| 222 | 227 | // routes back over the MCP bridge. |
| 223 | 228 | expect(content._meta).toMatchObject({ |