Unified · Split
| @@ -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": { |
| @@ -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 | |||
| 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 | const resolveOnePasswordCoreWasmPath = (): string => { | 54 | const 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 server | 450 | // 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 patched | 457 | // Copy 1Password's sdk-core WASM next to the binary. The patched |
| 427 | // sdk-core loader falls back to this sibling file when bun --compile | 458 | // 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. |
| @@ -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 | 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 | ||
| 31 | const APPS_CAPS = { | 32 | const 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; |
| @@ -123,7 +123,10 @@ const withClient = async <E extends Cause.YieldableError>( · 122 unmodified lines | |||
| 123 | 123 | ||
| 124 | const SHELL_HTML = "<!doctype html><html><body><div id='root'></div></body></html>"; | 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 | const APPS_CAPS = { | 130 | const 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; everything | 226 | // 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({ |