import test from "node:test"; import assert from "node:assert/strict"; import { mkdtemp, rm, readFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { createService } from "../server/index.ts"; import { unpackProject } from "../engine/archive.ts"; import { unzipSync } from "fflate"; import { triangleGlb } from "./fixtures.ts"; test("real MCP HTTP + stdio share revisions, models, scripts, resources and exports", async () => { const dir = await mkdtemp(path.join(os.tmpdir(), "forma-test-")); const s = await createService({ projectDir: dir, port: 0, blank: true }); const client = new Client({ name: "acceptance", version: "1" }); let bridge: Client | undefined; try { const url = "http://127.0.0.1:" + s.port + "/mcp"; const unauthorized = await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: "{}", }); assert.equal(unauthorized.status, 401); const origin = await fetch(url, { method: "POST", headers: { "content-type": "application/json", origin: "https://untrusted.example", authorization: "Bearer " + s.token, }, body: "{}", }); assert.equal(origin.status, 403); await client.connect( new StreamableHTTPClientTransport(new URL(url), { requestInit: { headers: { authorization: "Bearer " + s.token } }, }), ); const tools = await client.listTools(); assert.ok(tools.tools.length >= 25); assert.ok(tools.tools.some((t) => t.name === "runtime_capture")); const page = await fetch("http://127.0.0.1:" + s.port + "/").then((r) => r.text(), ); for (const asset of ["/studio/editor.js", "/studio/editor.css"]) { assert.ok(page.includes(asset)); assert.equal( (await fetch("http://127.0.0.1:" + s.port + asset)).status, 200, ); } const toggle = async (enabled: boolean) => fetch("http://127.0.0.1:" + s.port + "/api/mcp", { method: "POST", headers: { "content-type": "application/json", origin: "http://127.0.0.1:" + s.port, }, body: JSON.stringify({ enabled }), }); assert.equal((await toggle(false)).status, 200); await assert.rejects(() => client.callTool({ name: "project_read", arguments: {} }), ); assert.equal((await toggle(true)).status, 200); const resources = await client.listResources(); assert.equal(resources.resources.length, 3); assert.ok( (await client.readResource({ uri: "forma://reference/scripts" })).contents .length, ); const prompts = await client.listPrompts(); assert.deepEqual( prompts.prompts.map((p) => p.name), ["create_scene"], ); const prompt = await client.getPrompt({ name: "create_scene", arguments: { theme: "architecture" }, }); assert.match(JSON.stringify(prompt.messages), /architecture/); const invoke = async (name: string, args: any = {}) => { const r = await client.callTool({ name, arguments: args }); if (r.isError) throw Error(JSON.stringify(r.content)); return r.structuredContent as any; }; let p = await invoke("project_read"); assert.equal(p.revision, 0); assert.equal(p.assets.length, 0); assert.equal(p.scripts.length, 0); assert.equal(s.service.store.project.scenes[0].entities.length, 0); await invoke("model_generate", { kind: "arena", expectedRevision: 0, seed: 41, obstacles: 3, requestId: "arena-1", }); assert.equal(s.service.store.project.revision, 1); assert.ok(s.service.store.project.scenes[0].entities.length > 20); const stale = await client.callTool({ name: "node_create", arguments: { name: "stale", expectedRevision: 0 }, }); assert.equal(stale.isError, true); assert.equal(s.service.store.project.revision, 1); await invoke("model_generate", { kind: "arena", expectedRevision: 0, seed: 41, obstacles: 3, requestId: "arena-1", }); assert.equal(s.service.store.project.revision, 1); const bytes = triangleGlb(true); await invoke("asset_import_glb", { expectedRevision: 1, name: "test.glb", base64: bytes.toString("base64"), instantiate: true, }); assert.equal(s.service.store.project.revision, 2); bridge = new Client({ name: "stdio-acceptance", version: "1" }); await bridge.connect( new StdioClientTransport({ command: process.execPath, args: [ fileURLToPath(new URL("../server/stdio.mjs", import.meta.url)), "--project", dir, "--url", url, ], cwd: os.tmpdir(), stderr: "pipe", }), ); assert.equal((await bridge.listTools()).tools.length, tools.tools.length); const change = await bridge.callTool({ name: "node_create", arguments: { name: "From stdio", id: "stdio_object", expectedRevision: 2, components: { mesh: { type: "sphere", size: [1, 1, 1] } }, }, }); assert.equal(change.isError, undefined); assert.equal(s.service.store.project.revision, 3); assert.ok( s.service.store.project.scenes[0].entities.some( (n) => n.id === "stdio_object", ), ); await invoke("history_undo", { expectedRevision: 3 }); assert.equal(s.service.store.project.revision, 4); assert.ok( !s.service.store.project.scenes[0].entities.some( (n) => n.id === "stdio_object", ), ); await invoke("history_redo", { expectedRevision: 4 }); const save = await invoke("project_save"); assert.equal( unpackProject(new Uint8Array(await readFile(save.path))).revision, 5, ); const exported = await invoke("project_export_web"); const files = unzipSync(new Uint8Array(await readFile(exported.path))); assert.ok(files["index.html"] && files["player.js"] && files["player.css"]); assert.ok(files["player.js"].length > 100000); assert.equal( JSON.parse(new TextDecoder().decode(files["project.forma.json"])) .revision, 5, ); const noEditor = await client.callTool({ name: "runtime_play", arguments: {}, }); assert.equal(noEditor.isError, true); assert.match(JSON.stringify(noEditor.content), /EDITOR_DISCONNECTED/); } finally { await bridge?.close(); await client.close(); await s.close(); await rm(dir, { recursive: true, force: true }); } }); test("MCP scene ZIP import replaces the scene atomically and Undo restores the prior project", async () => { const dir = await mkdtemp(path.join(os.tmpdir(), "forma-scene-")); const s = await createService({ projectDir: dir, port: 0, blank: true }); const client = new Client({ name: "scene-test", version: "1" }); try { await client.connect( new StreamableHTTPClientTransport( new URL(`http://127.0.0.1:${s.port}/mcp`), { requestInit: { headers: { authorization: "Bearer " + s.token } } }, ), ); const original = structuredClone(s.service.store.project); const { zipSync } = await import("fflate"); const { sceneAnimationGlb } = await import("./fixtures.ts"); const archive = zipSync({ "scene.glb": sceneAnimationGlb() }); const result = await client.callTool({ name: "asset_import_glb", arguments: { expectedRevision: 0, name: "scene.zip", base64: Buffer.from(archive).toString("base64"), asScene: true, }, }); assert.ok(!result.isError, JSON.stringify(result.content)); const p = s.service.store.project, n = p.scenes[0].entities[0]; assert.equal(p.revision, 1); assert.equal(n.components.camera.mode, "imported"); assert.equal(n.components.animator.autoplay, "Scene"); const undo = await client.callTool({ name: "history_undo", arguments: { expectedRevision: 1 }, }); assert.ok(!undo.isError, JSON.stringify(undo.content)); assert.equal(s.service.store.project.id, original.id); assert.equal(s.service.store.project.assets.length, 0); } finally { await client.close(); await s.close(); await rm(dir, { recursive: true, force: true }); } }); test("MCP imports image assets, configures XY scenes, saves portable sprites and rejects escaping paths", async () => { const dir = await mkdtemp(path.join(os.tmpdir(), "forma-2d-")); const s = await createService({ projectDir: dir, port: 0, blank: true }), client = new Client({ name: "2d-test", version: "1" }); try { await client.connect( new StreamableHTTPClientTransport( new URL(`http://127.0.0.1:${s.port}/mcp`), { requestInit: { headers: { authorization: "Bearer " + s.token } } }, ), ); const call = (name: string, args: any) => client.callTool({ name, arguments: args }); const created = await call("scene_create", { name: "XY", mode: "2d", expectedRevision: s.service.store.project.revision, }); assert.ok(!created.isError, JSON.stringify(created)); const { activeScene } = await import("../engine/schema.ts"); assert.equal(activeScene(s.service.store.project).mode, "2d"); const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScLttAAAAABJRU5ErkJggg=="; const imported = await call("asset_import_image", { name: "pixel.png", base64: png, instantiate: true, expectedRevision: s.service.store.project.revision, }); assert.ok(!imported.isError, JSON.stringify(imported)); const project = s.service.store.project; assert.equal(project.assets[0].kind, "image"); assert.equal( activeScene(project).entities[0].components.sprite.assetId, project.assets[0].id, ); const bad = await call("asset_import_image", { name: "pixel.png", path: "../escape.png", expectedRevision: project.revision, }); assert.ok(bad.isError); assert.equal(s.service.store.project.revision, project.revision); await call("project_save", {}); const restored = unpackProject( new Uint8Array(await readFile(path.join(dir, "project.forma"))), ); assert.deepEqual(restored, project); } finally { await client.close(); await s.close(); await rm(dir, { recursive: true, force: true }); } });