import { describe, expect, it } from "@effect/vitest"; import { Effect } from "effect"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { EXTENSION_ID, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server"; import type { ClientCapabilities } from "@modelcontextprotocol/sdk/types.js"; import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server"; import { MCP_APPS_SHELL_RESOURCE_URI } from "@executor-js/host-mcp/create-artifact"; import type { ExecutionEngine } from "@executor-js/execution"; import { loadMcpAppsShellHtml } from "./shell-html"; import { MCP_APPS_SHELL_DOCUMENT_MARKER } from "./shell-asset-path"; // The shipped binary serves the shell from a file on disk (`build:shell` output, // copied next to the executable by `apps/cli/src/build.ts`). If that file is // missing the loader rejects the resource read — it used to degrade to a // placeholder document, which hung every MCP-Apps client silently — so assert // an MCP client reading the resource gets the REAL built shell. This replaces // a build-time subprocess probe that hard-slept 7 seconds inside every binary // build. const stubEngine: ExecutionEngine = { execute: () => Effect.succeed({ result: "ok" }), executeWithPause: () => Effect.succeed({ status: "completed", result: { result: "ok" } }), resume: () => Effect.succeed(null), isExecutionSettled: undefined, getPausedExecution: () => Effect.succeed(null), pausedExecutionCount: () => Effect.succeed(0), hasPausedExecutions: () => Effect.succeed(false), getDescription: Effect.succeed("smoke"), }; // oxlint-disable-next-line executor/no-double-cast -- boundary: MCP SDK ClientCapabilities predates the ext-apps `extensions` field const APPS_CAPS = { extensions: { [EXTENSION_ID]: { mimeTypes: [RESOURCE_MIME_TYPE] } }, } as unknown as ClientCapabilities; describe("MCP-Apps shell resource", () => { it("serves the built shell to an MCP client", async () => { const mcpServer = await Effect.runPromise( // Artifacts are opt-in per connection; the shell resource only exists on // a session that asked for them. createExecutorMcpServer({ engine: stubEngine, loadAppShellHtml: loadMcpAppsShellHtml, artifactsEnabled: true, }), ); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); const client = new Client( { name: "shell-smoke", version: "1.0.0" }, { capabilities: APPS_CAPS }, ); await mcpServer.connect(serverTransport); await client.connect(clientTransport); const read = await client.readResource({ uri: MCP_APPS_SHELL_RESOURCE_URI }); const [content] = read.contents; expect(content.mimeType).toBe(RESOURCE_MIME_TYPE); // The shell is served as text, never as a blob. expect("text" in content).toBe(true); const html = "text" in content ? content.text : ""; expect(html).toContain('id="root"'); // The identity marker the Workers loader authenticates the document by // (an ASSETS-binding miss under SPA not-found handling answers with // index.html and a 200). Asserted here so the marker leaving the template // fails this suite too, not just the app builds. expect(html).toContain(MCP_APPS_SHELL_DOCUMENT_MARKER); // `vite-plugin-singlefile` inlines every asset: a shell that still points at // a sibling bundle would 404 inside the host's sandboxed iframe. // // Scanned over the DOCUMENT only, with the inlined script bodies blanked // first. The bundle is ~5 MB of JavaScript that includes React's own source // strings, and several of those build markup as text // (`n += ' href="' + e.href + '"'` inside its resource-hint code). Matching // the raw document therefore reports tags that exist only as characters in // a string literal, which no browser will ever fetch — a false positive // that says nothing about the property under test. What matters is whether // the served document itself references anything it would have to go and // get. // Only INLINE scripts are blanked. A `", ); const externalRefs = [...documentOnly.matchAll(/<(?:script|link)[^>]*(?:src|href)="([^"]+)"/g)] .map((match) => match[1]) .filter((url) => !url.startsWith("data:")); expect(externalRefs).toEqual([]); await clientTransport.close(); await serverTransport.close(); }); });