import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod/v4"; import { ProjectStore } from "../engine/store.ts"; import { entity, validateGeometry } from "../engine/schema.ts"; import { defaultProject } from "../engine/templates.ts"; import { arena, character, extrude, lathe } from "../engine/geometry.ts"; export interface EngineService { builds?: import("./builds.ts").BuildManager; store: ProjectStore; status: () => any; save: () => Promise; exportWeb: () => Promise; importImage?: (a: any) => Promise; importModel: (a: any) => Promise; runtime: (action: string, args?: any) => Promise; } export const commandReference = { transactions: "1–1000 commands apply atomically. Read project_read first. Mutation expectedRevision must match current revision. requestId deduplicates successful retried transactions. On REVISION_CONFLICT reread project before editing.", coordinates: "Y up, meters, radians. Entity transforms local to parent. Reparent preserves local coordinates unless you provide transform. Kinematic move uses world displacement; other move uses local.", commands: { "project.rename": "{name}", "project.settings": '{background:"#dedbd2",ambient:0.85,shadows:true,renderScale:1}', "scene.create": "{id?,name,mode?:2d|3d}", "scene.configure": "{sceneId?,mode:2d|3d}; default editor view and fallback game camera", "scene.activate": "{id}", "scene.rename": "{sceneId,name}", "node.create": "{name,id?,position?,parentId?,components?,sceneId?} OR {entity:{id,name,parentId:null,enabled:true,transform:{position:[0,0,0],rotation:[0,0,0],scale:[1,1,1]},components:{}}}", "node.patch": "{id,patch:{name?,enabled?,transform?,components?},sceneId?}; deep merge, arrays replace, id/parentId immutable here", "node.reparent": "{id,parentId:null|string,transform?,sceneId?}", "node.delete": "{id,sceneId?}; subtree", "node.duplicate": "{id,sceneId?}; subtree and internal references", "component.set": "{id,type,value,sceneId?}; replaces component", "component.remove": "{id,type,sceneId?}", "asset.upsert": '{asset:{id,name,kind:"model"|"geometry"|"prefab"|"image",uri?,geometry?,entities?,metadata?,image?}}', "asset.delete": "{id}; fails if referenced", "script.upsert": '{script:{id,name,source,fields:{speed:{type:"number",default:5,label:"Speed",min:0,max:30}}}}', "prefab.create": "{id,name?,sceneId?}", "prefab.instantiate": "{assetId,position?,sceneId?}", }, components: { sprite: { assetId: "image asset or empty for white quad", frame: 0, size: "optional [width,height], otherwise frame pixels / PPU", color: "#ffffff", alpha: 1, layer: 0, order: 0, flipX: false, flipY: false, sortY: false, lit: false, }, spriteAnimator: { autoplay: "idle", autoStates: false, clips: [{ name: "idle", frames: [0, 1], fps: 8, loop: true }], }, tilemap: { assetId: "image asset", tileSize: [1, 1], width: 32, height: 18, cells: [{ x: 0, y: 0, frame: 0, solid: true }], collisions: false, layer: 0, order: 0, }, collider2d: { shape: "box | circle | capsule | polygon", size: [1, 1], radius: 0.5, height: 1.8, points: "convex polygon vertices [[x,y],...]", offset: [0, 0], sensor: false, oneWay: false, membership: 1, mask: 65535, }, rigidbody2d: { type: "fixed | dynamic | kinematic", mass: 1, friction: 0.5, restitution: 0, gravityScale: 1, lockRotation: true, ccd: true, requires: "collider2d or colliding tilemap", }, character2d: { mode: "platformer | topDown", controls: true, speed: 5, jumpSpeed: 8, gravity: 20, autostep: 0.2, requires: "kinematic rigidbody2d + collider2d", }, joint2d: { type: "fixed | revolute | rope | spring", targetId: "another body2d", anchor: [0, 0], targetAnchor: [0, 0], length: 1, stiffness: 50, damping: 5, }, mesh: { type: "box | sphere | cylinder | icosphere | torus | model | geometry | custom", size: [1, 1, 1], assetId: "for model/geometry types", geometry: "{positions,indices,normals?,uvs?} for custom type", }, material: { color: "#91a697", roughness: 0.8, metallic: 0, emissive: 0, override: false, }, collider: { shape: "box | ball | capsule", size: [1, 1, 1], radius: 0.32, height: 1.8, offset: [0, 0.9, 0], sensor: false, enabled: true, }, rigidbody: { type: "fixed | dynamic | kinematic", mass: 1, restitution: 0.1, }, camera: { mode: "follow | firstPerson | fixed | imported | 2d", orthoWidth: 20, pixelPerfect: false, pixelsPerUnit: 100, targetId: "subject", offset: [0, 13, -10], fov: 0.72, yaw: 0, pitch: 0, }, character: { gravity: 24, autostep: 0.25, requires: "kinematic rigidbody + capsule collider", }, sign: { text: "Text in the scene", color: "#d7f34b", width: 5 }, light: { color: "#fff1da", intensity: 2 }, animator: { idle: "Idle", run: "Run", attack: "Attack", death: "Death", speed: 1, }, script: { scriptId: "script_rotate", params: { speed: 1 }, }, data: { customValue: 1, label: "Application-defined properties" }, }, }; export const scriptReference = { source: "JavaScript expression returning {start(api), update(api,dt)}. No imports or TypeScript. Worker watchdog 1500ms. Imported project scripts are TRUSTED code; Worker is responsiveness isolation, not a security sandbox.", api: { state: "Persistent per-instance mutable data during one play run", params: "Field defaults + component.script.params", input: "{x,y,z,attack,pointer,aim,jump,dash,sprint,jumpPressed,dashPressed,resetPressed,yaw,pitch}; yaw=0 faces -Z in first person", get: "api.get(id?) -> clone of entity or null", entities: "api.entities() -> clones of entity states", position: "api.position(id?) -> local coordinates", move: "api.move([dx,dy,dz]); real collisions for kinematic body", physics: "api.physics(id?) -> {grounded,velocity:{x,y,z},contacts:[{entityId,normal:[x,y,z]}]}", velocity: "api.velocity({x?,y?,z?,gravityScale?}); persistent m/s, requires 3D character or any body2d; 2D uses x/y. Gravity runs at 60 Hz", teleport: "api.teleport([x,y,z],yaw?); clears velocity and contacts for character respawn", emit: "api.emit(name,data?); delivers a presentation event to runtime callbacks", rotate: "api.rotate(yRadians)", rotate2D: "api.rotate2D(zRadians)", impulse2D: "api.impulse2D([x,y]); applies impulse to dynamic body2d", collision2d: "Optional behavior hook collision2d(api,event): {entityId,otherId,started,sensor}. Runs before update.", patch: "api.patch(id,patch); updates state/transform/enabled. Structural mesh/collider edits take effect next Play.", animate: "api.animate(clipOrState,loop=true); use false for a one-shot animation", effect: 'api.effect("swing"|"hit",id?)', spawn: "api.spawn(prefabAssetId,position); behaviors start on spawned instances", destroy: "api.destroy(id?); disables entity+collider", scene: "api.scene(sceneId); starts another scene", log: "api.log(text)", }, example: "({ update(api, dt) { const n = api.get(); api.rotate(n.transform.rotation[1] + api.params.speed * dt); } })", }; const json = z.record(z.string(), z.unknown()), vector = z.tuple([z.number(), z.number(), z.number()]), revision = { expectedRevision: z.number().int().nonnegative(), requestId: z.string().max(100).optional(), }; export function createMcp(s: EngineService) { const server = new McpServer( { name: "forma-engine", version: "0.3.0" }, { instructions: "Read project_read and forma://reference/commands first. Use expectedRevision, atomic transactions and verify runtime evidence. This is a real local engine. Do not claim testing without observed runtime tool results.", }, ); function tool( name: string, description: string, inputSchema: any, fn: (a: any) => any, readOnlyHint = false, ) { server.registerTool( name, { description, inputSchema, annotations: { readOnlyHint, destructiveHint: !readOnlyHint, idempotentHint: readOnlyHint, openWorldHint: false, }, }, async (a: any): Promise => { try { const r = await fn(a); if ( typeof r?.image === "string" && r.image.startsWith("data:image/png;base64,") ) { const { image, ...meta } = r; return { content: [ { type: "image", mimeType: "image/png", data: image.split(",")[1], }, { type: "text", text: JSON.stringify(meta) }, ], structuredContent: meta, }; } return { content: [{ type: "text", text: JSON.stringify(r) }], structuredContent: r, }; } catch (e) { return { isError: true, content: [{ type: "text", text: String(e) }], }; } }, ); } const builds = () => { if (!s.builds) throw Error("BUILD_SERVICE_UNAVAILABLE"); return s.builds; }; tool( "build_targets", "Check local app build tools and Android release signing availability.", {}, () => builds().capabilities(), true, ); tool( "build_start", "Build an immutable project snapshot into Linux x64 AppImage, Windows x64 portable EXE or Android APK. Returns a job; poll build_status until terminal. First-time tool downloads need network. Never claim device testing from package success.", { expectedRevision: z.number().int().nonnegative(), options: z.object({ target: z.enum(["linux", "windows", "android"]), name: z.string().optional(), appId: z.string().optional(), version: z.string().optional(), versionCode: z.number().int().optional(), mode: z.enum(["debug", "release"]).optional(), width: z.number().int().optional(), height: z.number().int().optional(), fullscreen: z.boolean().optional(), orientation: z.enum(["landscape", "portrait", "sensor"]).optional(), }), }, (a: any) => builds().start(s.store.project, a.options, a.expectedRevision), ); tool( "build_status", "Read build status, bounded log and artifact URLs with SHA-256.", { id: z.string() }, (a: any) => builds().get(a.id), true, ); tool( "build_list", "List recent local build jobs.", {}, () => ({ jobs: builds().list() }), true, ); tool( "build_cancel", "Cancel a queued or running build and stop its child processes.", { id: z.string() }, (a: any) => builds().cancel(a.id), ); const tx = (a: any, commands: any[], label: string) => s.store.transaction({ commands, expectedRevision: a.expectedRevision, requestId: a.requestId, label, source: "mcp", }); tool( "project_read", "Project revision, scenes, asset summaries, scripts, settings and editor status. Binary model data omitted.", {}, () => { const p = s.store.project; return { ...s.status(), id: p.id, name: p.name, revision: p.revision, activeSceneId: p.activeSceneId, scenes: p.scenes.map((s) => ({ id: s.id, name: s.name, objects: s.entities.length, })), assets: p.assets.map(({ uri, geometry, entities, ...rest }) => rest), scripts: p.scripts, settings: p.settings, }; }, true, ); tool( "project_new", "Replace active project with an empty scene. Undoable. Save your current project first.", { ...revision, name: z.string().max(200), template: z.literal("empty").default("empty"), }, (a) => { const p = defaultProject(); p.name = a.name; return tx( a, [{ op: "project.replace", args: { project: p } }], "Новый проект", ); }, ); tool( "scene_read", "Read entity hierarchy and components. Geometry arrays omitted unless requested.", { sceneId: z.string().optional(), includeGeometry: z.boolean().default(false), }, (a) => { const scene = s.store.project.scenes.find( (p) => p.id === (a.sceneId || s.store.project.activeSceneId), ); if (!scene) throw Error("Scene not found"); const result = structuredClone(scene); if (!a.includeGeometry) for (const n of result.entities) { const g = n.components.mesh?.geometry; if (g) n.components.mesh.geometry = { vertexCount: g.positions.length / 3, triangles: g.indices.length / 3, }; } return { revision: s.store.project.revision, scene: result }; }, true, ); tool( "scene_create", "Create and activate a scene.", { ...revision, name: z.string(), id: z.string().optional(), mode: z.enum(["2d", "3d"]).optional(), }, (a) => tx( a, [ { op: "scene.create", args: { name: a.name, ...(a.id ? { id: a.id } : {}), ...(a.mode ? { mode: a.mode } : {}), }, }, ], "Создать сцену", ), ); tool( "commands_apply", "Apply one atomic command batch. Read forma://reference/commands for schemas.", { ...revision, label: z.string().max(200), commands: z .array(z.object({ op: z.string(), args: json })) .min(1) .max(1000), }, (a) => tx(a, a.commands, a.label), ); tool( "node_create", "Create a 3D entity with arbitrary components.", { ...revision, name: z.string(), id: z.string().optional(), parentId: z.string().optional(), position: vector.default([0, 0, 0]), components: json.default({}), }, (a) => tx( a, [ { op: "node.create", args: { name: a.name, position: a.position, components: a.components, ...(a.id ? { id: a.id } : {}), ...(a.parentId ? { parentId: a.parentId } : {}), }, }, ], "Создать " + a.name, ), ); tool( "node_update", "Deep merge object properties. Use node.reparent command to change hierarchy.", { ...revision, id: z.string(), patch: json }, (a) => tx( a, [{ op: "node.patch", args: { id: a.id, patch: a.patch } }], "Изменить объект", ), ); for (const op of ["delete", "duplicate"]) tool( "node_" + op, op + " object subtree.", { ...revision, id: z.string() }, (a) => tx(a, [{ op: "node." + op, args: { id: a.id } }], op), ); tool( "model_generate", "Generate editable geometry WITHOUT Blender. arena: level; character: static stylized figure; extrude: polygon [x,z]+depth; lathe: profile [radius,y]+segments.", { ...revision, kind: z.enum(["arena", "character", "extrude", "lathe"]), name: z.string().optional(), width: z.number().min(8).max(80).optional(), depth: z.number().min(0.05).max(80).optional(), height: z.number().min(0.1).max(20).optional(), seed: z.number().int().optional(), obstacles: z.number().int().min(0).max(80).optional(), segments: z.number().int().min(3).max(128).optional(), color: z .string() .regex(/^#[\da-fA-F]{6}$/) .optional(), profile: z .array(z.tuple([z.number(), z.number()])) .max(256) .optional(), }, (a) => { let nodes; if (a.kind === "arena") nodes = arena(a); else if (a.kind === "character") nodes = character(a); else { if (!a.profile) throw Error("profile required"); const geometry = a.kind === "extrude" ? extrude(a.profile, a.depth || 1) : lathe(a.profile, a.segments || 24); nodes = [ entity(a.name || a.kind, { mesh: { type: "custom", geometry }, material: { color: a.color || "#91a697", roughness: 0.8 }, }), ]; } if (a.name) nodes[0].name = a.name; return tx( a, nodes.map((n) => ({ op: "node.create", args: { entity: n } })), "Генерация " + a.kind, ); }, ); tool( "mesh_create", "Create custom triangle mesh directly from vertex arrays.", { ...revision, name: z.string(), positions: z.array(z.number()).max(900000), indices: z.array(z.number().int()).max(1800000), normals: z.array(z.number()).optional(), uvs: z.array(z.number()).optional(), color: z.string().default("#91a697"), }, (a) => { const geometry = { positions: a.positions, indices: a.indices, ...(a.normals ? { normals: a.normals } : {}), ...(a.uvs ? { uvs: a.uvs } : {}), }; validateGeometry(geometry); return tx( a, [ { op: "node.create", args: { entity: entity(a.name, { mesh: { type: "custom", geometry }, material: { color: a.color, roughness: 0.8 }, }), }, }, ], "Создать сетку", ); }, ); tool( "asset_import_image", "Import PNG, JPEG or WebP as an image asset and optional XY sprite. Configure asset.image with asset.upsert for slicing, PPU, filter and pivots.", { ...revision, name: z.string().regex(/\.(png|jpe?g|webp)$/i), base64: z.string().max(36_000_000).optional(), path: z.string().optional(), instantiate: z.boolean().default(true), }, (a) => { if (!s.importImage) throw Error("Image import unavailable"); return s.importImage(a); }, ); tool( "asset_import_glb", "Import GLB, glTF with companion files, or a ZIP containing one model and its textures. Use base64 bytes OR a path inside the project folder. External files resolve only within that folder; network URLs are not fetched.", { ...revision, name: z.string().regex(/\.(glb|gltf|zip)$/i), base64: z.string().max(36_000_000).optional(), path: z.string().optional(), instantiate: z.boolean().default(true), asScene: z .boolean() .default(false) .describe( "Replace the project with the complete imported scene, using its active/first camera and lights. Undoable.", ), }, (a) => s.importModel(a), ); tool( "script_upsert", "Create or edit trusted JavaScript behavior and Inspector fields.", { ...revision, id: z.string(), name: z.string(), source: z.string().max(250000), fields: json, }, (a) => { new Function("return (" + a.source + ");"); return tx( a, [ { op: "script.upsert", args: { script: { id: a.id, name: a.name, source: a.source, fields: a.fields, }, }, }, ], "Изменить скрипт " + a.name, ); }, ); tool( "prefab_create", "Capture object subtree as prefab asset.", { ...revision, id: z.string(), name: z.string().optional() }, (a) => tx( a, [ { op: "prefab.create", args: { id: a.id, ...(a.name ? { name: a.name } : {}) }, }, ], "Создать префаб", ), ); tool( "prefab_instantiate", "Instantiate prefab and remap internal object references.", { ...revision, assetId: z.string(), position: vector.default([0, 0, 0]) }, (a) => tx( a, [ { op: "prefab.instantiate", args: { assetId: a.assetId, position: a.position }, }, ], "Добавить префаб", ), ); for (const action of ["undo", "redo"] as const) tool( "history_" + action, action + " last transaction.", { expectedRevision: revision.expectedRevision }, (a) => { if (a.expectedRevision !== s.store.project.revision) throw Error("REVISION_CONFLICT"); s.store[action](); return { revision: s.store.project.revision }; }, ); tool( "project_save", "Persist JSON and portable .forma archive to project directory.", {}, () => s.save(), ); tool( "project_export_web", "Export standalone HTML+runtime+assets as ZIP in exports/. Does not publish.", {}, () => s.exportWeb(), ); for (const action of ["play", "stop", "snapshot", "capture"]) tool( "runtime_" + action, action + " in the connected editor. Requires a live browser editor. Capture returns actual PNG.", {}, () => s.runtime(action), ["snapshot", "capture"].includes(action), ); tool( "runtime_input", "Send timed movement, first-person view, jump, dash or attack to the running game and return observed state.", { x: z.number().min(-1).max(1).default(0), y: z .number() .min(-1) .max(1) .optional() .describe("Vertical XY input for 2D"), z: z.number().min(-1).max(1).default(0), attack: z.boolean().default(false), jump: z.boolean().default(false), dash: z.boolean().default(false), sprint: z.boolean().default(false), reset: z.boolean().default(false), yaw: z.number().optional(), pitch: z.number().min(-1.3).max(1.3).optional(), pointer: z.boolean().default(false), aim: vector.optional(), durationMs: z.number().int().min(50).max(10000).default(500), }, (a) => s.runtime("input", a), ); tool( "editor_focus", "Focus editor camera on an entity.", { id: z.string() }, (a) => s.runtime("focus", a), ); const resource = (name: string, uri: string, data: () => any) => server.registerResource( name, uri, { mimeType: "application/json" }, async () => ({ contents: [ { uri, mimeType: "application/json", text: JSON.stringify(data(), null, 2), }, ], }), ); resource("Current project", "forma://project/current", () => s.store.project); resource("Commands", "forma://reference/commands", () => commandReference); resource("Scripts", "forma://reference/scripts", () => scriptReference); server.registerPrompt( "create_scene", { description: "Create and verify a 3D scene from an empty project.", argsSchema: { theme: z.string().optional() }, }, ({ theme }) => ({ messages: [ { role: "user", content: { type: "text", text: "Build a 3D scene" + (theme ? " themed " + theme : "") + ". Read project metadata and command/script resources. Save the current project before replacing. Start empty, create or import geometry, configure materials and lighting, and add a camera. Add behaviors only when required by the scene. Use atomic revision-checked changes. Verify the scene and any scripted behavior with runtime tools; capture PNG when an editor is connected, save and export. Do not claim a check passed without evidence.", }, }, ], }), ); return server; }