Publish Forma Engine 0.3.0 source with documentation and CI

This commit is contained in:
emil28092005
2026-09-09 15:49:08 +03:00
commit e52bc0e33b
70 changed files with 19610 additions and 0 deletions
+312
View File
@@ -0,0 +1,312 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import { spawn, type ChildProcess } from "node:child_process";
import { fileURLToPath } from "node:url";
import { randomUUID, createHash } from "node:crypto";
import { unzipSync } from "fflate";
import { gameArchive } from "../engine/archive.ts";
import { clone, type Project } from "../engine/schema.ts";
import { normalizeOptions } from "../native/options.mjs";
import { doctor } from "../native/build.mjs";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
export type BuildJob = {
id: string;
status: "queued" | "building" | "succeeded" | "failed" | "cancelled";
revision: number;
projectId: string;
createdAt: string;
finishedAt?: string;
options: ReturnType<typeof normalizeOptions>;
logs: string[];
error?: string;
artifacts?: {
name: string;
bytes: number;
sha256: string;
url: string;
path: string;
}[];
};
export class BuildManager {
private jobs = new Map<string, BuildJob>();
private queue: { id: string; project: Project }[] = [];
private child: ChildProcess | null = null;
private running: string | null = null;
private stopped = false;
private idle: Promise<void> = Promise.resolve();
private saved: Promise<void> = Promise.resolve();
readonly directory: string;
constructor(
projectDir: string,
private read: (uri: string) => Promise<Uint8Array>,
) {
this.directory = path.join(projectDir, "builds");
}
async init() {
await fs.mkdir(this.directory, { recursive: true });
for (const n of await fs.readdir(this.directory))
if (/^[a-f0-9-]{36}$/.test(n)) {
try {
const j = JSON.parse(
await fs.readFile(path.join(this.directory, n, "job.json"), "utf8"),
) as BuildJob;
if (j.id !== n) continue;
if (["queued", "building"].includes(j.status)) {
j.status = "failed";
j.error = "Build interrupted by server restart";
j.finishedAt = new Date().toISOString();
await this.persist(j);
}
this.jobs.set(n, j);
} catch {
/* An unfinished metadata write must not prevent opening the project. */
}
}
}
capabilities() {
return doctor();
}
list() {
return [...this.jobs.values()]
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
.slice(0, 30)
.map((j) => ({ ...j, logs: j.logs.slice(-80) }));
}
get(id: string) {
const j = this.jobs.get(id);
if (!j) throw Error("Build not found");
return structuredClone(j);
}
private persist(job: BuildJob) {
const data = JSON.stringify(job, null, 2),
file = path.join(this.directory, job.id, "job.json");
this.saved = this.saved
.catch(() => {})
.then(async () => {
await fs.mkdir(path.dirname(file), { recursive: true });
await fs.writeFile(file + ".tmp", data, { mode: 0o600 });
await fs.rename(file + ".tmp", file);
});
return this.saved;
}
async start(project: Project, raw: any, expectedRevision: number) {
if (this.stopped) throw Error("Build service closed");
if (expectedRevision !== project.revision)
throw Error("REVISION_CONFLICT: reread project before building");
if (this.queue.length >= 3)
throw Error("Build queue full (maximum 3 waiting jobs)");
const options = normalizeOptions(raw),
cap = await this.capabilities();
if (!cap.targets[options.target as keyof typeof cap.targets].ready)
throw Error(
"BUILD_TOOLS_MISSING: " +
cap.targets[options.target as keyof typeof cap.targets].missing.join(
"; ",
),
);
if (
options.target === "android" &&
options.mode === "release" &&
!cap.targets.android.releaseSigningConfigured
)
throw Error(
"RELEASE_SIGNING_MISSING: configure signing in the local server environment",
);
if (expectedRevision !== project.revision) throw Error("REVISION_CONFLICT");
if (this.stopped) throw Error("Build service closed");
if (this.queue.length >= 3) throw Error("Build queue full");
const job: BuildJob = {
id: randomUUID(),
status: "queued",
revision: project.revision,
projectId: project.id,
createdAt: new Date().toISOString(),
options,
logs: ["Snapshot revision " + project.revision],
};
this.jobs.set(job.id, job);
this.queue.push({ id: job.id, project: clone(project) });
await this.persist(job);
this.pump();
return this.get(job.id);
}
private pump() {
if (this.running || this.stopped) return;
const next = this.queue.shift();
if (!next) return;
this.running = next.id;
this.idle = this.execute(next).finally(() => {
this.running = null;
this.child = null;
this.pump();
});
}
private async execute({ id, project }: { id: string; project: Project }) {
const j = this.jobs.get(id)!;
const folder = path.join(this.directory, id);
try {
j.status = "building";
await this.persist(j);
const bytes = await gameArchive(project, this.read);
if ((j.status as string) === "cancelled" || this.stopped) return;
const web = path.join(folder, "game");
await fs.mkdir(web, { recursive: true });
for (const [name, data] of Object.entries(unzipSync(bytes))) {
if (name.includes("..") || name.startsWith("/") || name.includes("\\"))
throw Error("Unsafe game path");
const file = path.join(web, name);
await fs.mkdir(path.dirname(file), { recursive: true });
await fs.writeFile(file, data);
}
await fs.writeFile(
path.join(folder, "build-config.json"),
JSON.stringify(j.options),
);
await fs.writeFile(
path.join(folder, "project-snapshot.json"),
JSON.stringify(project),
);
if ((j.status as string) === "cancelled" || this.stopped) return;
await new Promise<void>((resolve, reject) => {
const child = spawn(
process.execPath,
[
path.join(root, "native", "build.mjs"),
"--config",
path.join(folder, "build-config.json"),
"--game",
web,
"--out",
path.join(folder, "output"),
],
{
cwd: root,
stdio: ["ignore", "pipe", "pipe"],
detached: process.platform !== "win32",
windowsHide: true,
},
);
this.child = child;
let remainder = "";
const append = (data: Buffer) => {
remainder += data.toString();
const lines = remainder.split(/[\r\n]+/);
remainder = lines.pop() || "";
for (const line of lines)
if (line) j.logs.push(this.redact(line).slice(0, 1000));
if (remainder.length > 10000) {
j.logs.push(this.redact(remainder).slice(0, 1000));
remainder = "";
}
j.logs = j.logs.slice(-500);
};
child.stdout?.on("data", append);
child.stderr?.on("data", append);
const timer = setTimeout(
() => {
j.error = "Build timed out after 20 minutes";
this.kill(child);
},
20 * 60 * 1000,
);
child.on("error", (e) => {
clearTimeout(timer);
reject(e);
});
child.on("close", (code) => {
clearTimeout(timer);
if (remainder) j.logs.push(this.redact(remainder).slice(0, 1000));
code === 0
? resolve()
: reject(Error(j.error || "Builder exited " + code));
});
});
if ((j.status as string) === "cancelled") return;
const manifest = JSON.parse(
await fs.readFile(
path.join(folder, "output", "build-manifest.json"),
"utf8",
),
);
j.artifacts = [];
for (const f of manifest.files) {
if (path.basename(f.name) !== f.name)
throw Error("Invalid build output");
const b = await fs.readFile(
path.join(folder, "output", "artifacts", f.name),
);
if (createHash("sha256").update(b).digest("hex") !== f.sha256)
throw Error("Artifact checksum mismatch");
j.artifacts.push({
...f,
path: path.join(folder, "output", "artifacts", f.name),
url: "/api/builds/" + id + "/files/" + encodeURIComponent(f.name),
});
}
if (!j.artifacts.length) throw Error("No artifacts");
j.status = "succeeded";
j.logs.push(
"Application package verified. Device execution is a separate check.",
);
} catch (e) {
if (j.status !== "cancelled") {
j.status = "failed";
j.error = this.redact(String(e));
j.logs.push(j.error);
}
} finally {
j.finishedAt = new Date().toISOString();
await this.persist(j);
}
}
private redact(s: string) {
for (const key of [
"FORMA_KEYSTORE_PASSWORD",
"FORMA_KEY_PASSWORD",
"FORMA_KEY_ALIAS",
"FORMA_KEYSTORE",
]) {
const value = process.env[key];
if (value) s = s.split(value).join("[redacted]");
}
return s.replace(/\x1b\[[0-9;]*m/g, "");
}
private kill(child: ChildProcess) {
const send = (signal: NodeJS.Signals) => {
try {
if (process.platform !== "win32" && child.pid)
process.kill(-child.pid, signal);
else child.kill(signal);
} catch {}
};
send("SIGTERM");
const timer = setTimeout(() => send("SIGKILL"), 4000);
timer.unref();
child.once("close", () => clearTimeout(timer));
}
async cancel(id: string) {
const j = this.jobs.get(id);
if (!j) throw Error("Build not found");
if (["queued", "building"].includes(j.status)) {
j.status = "cancelled";
j.finishedAt = new Date().toISOString();
this.queue = this.queue.filter((n) => n.id !== id);
if (this.running === id && this.child) this.kill(this.child);
await this.persist(j);
}
return this.get(id);
}
async artifact(id: string, name: string) {
const j = this.get(id);
if (j.status !== "succeeded" || !j.artifacts?.some((f) => f.name === name))
throw Error("Artifact not found");
return path.join(this.directory, id, "output", "artifacts", name);
}
async close() {
this.stopped = true;
for (const j of this.jobs.values())
if (["queued", "building"].includes(j.status)) await this.cancel(j.id);
await this.idle;
await this.saved;
}
}
+558
View File
@@ -0,0 +1,558 @@
import http, { type IncomingMessage, type ServerResponse } from "node:http";
import { promises as fs } from "node:fs";
import path from "node:path";
import { createReadStream } from "node:fs";
import { BuildManager } from "./builds.ts";
import { fileURLToPath } from "node:url";
import { randomBytes, timingSafeEqual } from "node:crypto";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { ProjectStore } from "../engine/store.ts";
import { defaultProject } from "../engine/templates.ts";
import { entity, uid, validateProject } from "../engine/schema.ts";
import { projectArchive, gameArchive, decodeData } from "../engine/archive.ts";
import { createMcp, type EngineService } from "./mcp.ts";
import { inspectModel } from "../engine/model.ts";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const mime: Record<string, string> = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json",
".glb": "model/gltf-binary",
".gltf": "model/gltf+json",
".svg": "image/svg+xml",
".wasm": "application/wasm",
".png": "image/png",
".zip": "application/zip",
};
export function inside(base: string, requested: string) {
const p = path.resolve(base, requested);
if (p !== base && !p.startsWith(base + path.sep))
throw Error("Path must stay inside project directory");
return p;
}
async function safeRead(base: string, file: string) {
const candidate = inside(base, file),
real = await fs.realpath(candidate);
inside(base, path.relative(base, real));
return new Uint8Array(await fs.readFile(real));
}
async function atomic(file: string, data: string | Uint8Array) {
await fs.mkdir(path.dirname(file), { recursive: true });
const temp = file + ".tmp-" + randomBytes(6).toString("hex");
try {
await fs.writeFile(temp, data, { mode: 0o600 });
await fs.rename(temp, file);
} finally {
await fs.rm(temp, { force: true });
}
}
async function body(req: IncomingMessage) {
const chunks: Buffer[] = [];
let size = 0;
for await (const c of req) {
size += c.length;
if (size > 50 * 1024 * 1024) throw Error("Request limit is 50 MB");
chunks.push(c);
}
return JSON.parse(Buffer.concat(chunks).toString() || "{}");
}
function json(res: ServerResponse, status: number, data: any) {
res.writeHead(status, {
"content-type": "application/json; charset=utf-8",
"cache-control": "no-store",
"x-content-type-options": "nosniff",
});
res.end(JSON.stringify(data));
}
export async function createService(
options: {
projectDir?: string;
port?: number;
editorOrigin?: string;
token?: string;
blank?: boolean;
} = {},
) {
const projectDir = path.resolve(options.projectDir || "projects/MyGame");
await fs.mkdir(projectDir, { recursive: true });
const projectFile = path.join(projectDir, "project.forma.json"),
tokenFile = path.join(projectDir, ".mcp-token");
let token = options.token;
if (!token) {
try {
token = (await fs.readFile(tokenFile, "utf8")).trim();
} catch (e: any) {
if (e.code !== "ENOENT") throw e;
token = randomBytes(32).toString("hex");
await fs.writeFile(tokenFile, token, { mode: 0o600, flag: "wx" });
}
}
if (token.length < 24)
throw Error("MCP token must be at least 24 characters");
let project;
try {
project = JSON.parse(await fs.readFile(projectFile, "utf8"));
validateProject(project);
} catch (e: any) {
if (e.code !== "ENOENT")
throw Error("Original project retained; cannot open: " + String(e));
project = defaultProject(options.blank || false);
}
const store = new ProjectStore(project),
subscribers = new Set<ServerResponse>(),
pending = new Map<
string,
{
resolve: (a: any) => void;
reject: (e: any) => void;
timer: any;
client: ServerResponse;
}
>();
let mcpEnabled = true;
let port = options.port ?? 4318,
saveQueue = Promise.resolve(),
lastSaveError: string | null = null;
const auth = (req: IncomingMessage) => {
const a = Buffer.from(
req.headers.authorization?.replace(/^Bearer /, "") || "",
),
b = Buffer.from(token!);
return a.length === b.length && timingSafeEqual(a, b);
};
const status = () => ({
forma: true,
version: "0.3.0",
mcpEnabled,
mcpUrl: "http://127.0.0.1:" + port + "/mcp",
canUndo: store.canUndo,
canRedo: store.canRedo,
editorConnected: subscribers.size > 0,
projectFolder: path.basename(projectDir),
lastSaveError,
});
const state = () => ({
project: store.project,
history: store.history,
status: status(),
});
const broadcast = (event: string, data: any) => {
for (const r of subscribers)
r.write("event: " + event + "\ndata: " + JSON.stringify(data) + "\n\n");
};
const persist = () => {
const data = JSON.stringify(store.project, null, 2);
saveQueue = saveQueue
.catch(() => {})
.then(() => atomic(projectFile, data))
.then(() => {
lastSaveError = null;
})
.catch((e) => {
lastSaveError = String(e);
throw e;
});
return saveQueue;
};
store.subscribe(() => {
broadcast("project", state());
void persist().catch(() => broadcast("project", state()));
});
const readAsset = async (uri: string): Promise<Uint8Array> => {
if (uri.startsWith("data:")) return decodeData(uri);
const clean = uri.replace(/^\/+/, "").replace(/^\.\//, "");
if (!/^(assets|engine)\/[a-zA-Z0-9_.-]+$/.test(clean))
throw Error("Invalid asset path");
if (clean.startsWith("assets/"))
try {
return await safeRead(projectDir, clean);
} catch (e: any) {
if (e.code !== "ENOENT") throw e;
}
return safeRead(path.join(root, "public"), clean);
};
const builds = new BuildManager(projectDir, readAsset);
await builds.init();
const service: EngineService = {
builds,
store,
status,
save: async () => {
const p = structuredClone(store.project);
await persist();
const file = path.join(projectDir, "project.forma");
await atomic(file, await projectArchive(p, readAsset));
return { path: file, revision: p.revision };
},
exportWeb: async () => {
const p = structuredClone(store.project),
file = path.join(
projectDir,
"exports",
p.name.replace(/[^a-zA-Zа-яА-Я0-9_-]/g, "_") + "-web.zip",
);
await atomic(file, await gameArchive(p, readAsset));
return {
path: file,
revision: p.revision,
bytes: (await fs.stat(file)).size,
instructions:
"Unzip and serve over HTTP. Entry index.html. No editor or MCP required.",
};
},
importModel: async (a: any) => {
if (Boolean(a.base64) === Boolean(a.path))
throw Error("Supply exactly one of base64 or path");
const bytes = a.base64
? Buffer.from(a.base64, "base64")
: Buffer.from(await safeRead(projectDir, a.path));
const metadata = inspectModel(bytes, a.name),
gltf = a.name.toLowerCase().endsWith(".gltf");
const id = uid("asset"),
asset = {
id,
name: a.name,
kind: "model",
metadata,
uri:
"data:" +
(gltf ? "model/gltf+json" : "model/gltf-binary") +
";base64," +
bytes.toString("base64"),
},
commands: any[] = [{ op: "asset.upsert", args: { asset } }];
if (a.instantiate)
commands.push({
op: "node.create",
args: {
entity: entity(a.name.replace(/\.(glb|gltf)$/i, ""), {
mesh: { type: "model", assetId: id },
}),
},
});
return store.transaction({
commands,
expectedRevision: a.expectedRevision,
requestId: a.requestId,
label: "Импорт " + a.name,
source: "mcp",
});
},
runtime: (action, args = {}) =>
new Promise((resolve, reject) => {
const client = [...subscribers][0];
if (!client) {
reject(Error("EDITOR_DISCONNECTED: open local editor in browser"));
return;
}
const id = uid("bridge"),
timer = setTimeout(() => {
pending.delete(id);
reject(Error("EDITOR_TIMEOUT after 15 seconds"));
}, 15000);
pending.set(id, { resolve, reject, timer, client });
client.write(
"event: runtime\ndata: " +
JSON.stringify({ id, action, args }) +
"\n\n",
);
}),
};
const server = http.createServer(async (req, res) => {
try {
const host = req.headers.host || "";
if (!/^((127\.0\.0\.1|localhost)(:\d+)?|\[::1\](:\d+)?)$/.test(host)) {
json(res, 403, { error: "Invalid Host" });
return;
}
const origin = req.headers.origin,
origins = new Set([
"http://127.0.0.1:" + port,
"http://localhost:" + port,
...(options.editorOrigin ? [options.editorOrigin] : []),
]);
if (origin && !origins.has(origin)) {
json(res, 403, { error: "Origin not allowed" });
return;
}
const url = new URL(req.url || "/", "http://127.0.0.1:" + port),
pathname = decodeURIComponent(url.pathname);
if (pathname === "/mcp") {
if (!auth(req)) {
res.setHeader("www-authenticate", 'Bearer realm="Forma"');
json(res, 401, { error: "Bearer token required" });
return;
}
if (!mcpEnabled) {
json(res, 403, { error: "MCP_DISABLED_BY_USER" });
return;
}
if (req.method !== "POST") {
res.setHeader("allow", "POST");
json(res, 405, { error: "Use POST for stateless Streamable HTTP" });
return;
}
const data = await body(req),
mcp = createMcp(service),
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
enableJsonResponse: true,
});
res.once("close", () => void mcp.close());
await mcp.connect(transport);
await transport.handleRequest(req, res, data);
return;
}
if (pathname.startsWith("/api/")) {
if (req.method === "POST" && !origin && !auth(req)) {
json(res, 403, {
error: "Same-origin editor or bearer authentication required",
});
return;
}
if (req.method === "GET" && pathname === "/api/builds/capabilities") {
json(res, 200, await builds.capabilities());
return;
}
if (req.method === "GET" && pathname === "/api/builds") {
json(res, 200, { jobs: builds.list() });
return;
}
const buildFile = pathname.match(
/^\/api\/builds\/([a-f0-9-]{36})\/files\/([^/]+)$/,
);
if (req.method === "GET" && buildFile) {
const file = await builds.artifact(buildFile[1], buildFile[2]);
const stat = await fs.stat(file);
res.writeHead(200, {
"content-type": "application/octet-stream",
"content-length": stat.size,
"content-disposition":
"attachment; filename*=UTF-8''" +
encodeURIComponent(buildFile[2]),
"x-content-type-options": "nosniff",
});
const stream = createReadStream(file);
stream.on("error", () => res.destroy());
res.on("close", () => stream.destroy());
stream.pipe(res);
return;
}
const buildId = pathname.match(/^\/api\/builds\/([a-f0-9-]{36})$/);
if (req.method === "GET" && buildId) {
json(res, 200, builds.get(buildId[1]));
return;
}
if (req.method === "GET" && pathname === "/api/status") {
json(res, 200, status());
return;
}
if (req.method === "GET" && pathname === "/api/project") {
json(res, 200, state());
return;
}
if (req.method === "GET" && pathname === "/api/events") {
res.writeHead(200, {
"content-type": "text/event-stream",
"cache-control": "no-cache",
connection: "keep-alive",
"x-accel-buffering": "no",
});
res.write(": Forma connected\n\n");
subscribers.add(res);
res.write(
"event: project\ndata: " + JSON.stringify(state()) + "\n\n",
);
const heartbeat = setInterval(
() => res.write(": heartbeat\n\n"),
20000,
);
req.on("close", () => {
clearInterval(heartbeat);
subscribers.delete(res);
for (const [id, p] of pending)
if (p.client === res) {
clearTimeout(p.timer);
p.reject(Error("EDITOR_DISCONNECTED"));
pending.delete(id);
}
});
return;
}
if (req.method === "POST") {
const data = await body(req);
if (pathname === "/api/builds") {
json(
res,
202,
await builds.start(
store.project,
data.options,
data.expectedRevision,
),
);
return;
}
if (pathname === "/api/builds/cancel") {
json(res, 200, await builds.cancel(data.id));
return;
}
if (pathname === "/api/transaction") {
const result = store.transaction(data);
json(res, 200, { ...state(), result });
return;
}
if (pathname === "/api/history") {
if (!["undo", "redo"].includes(data.action))
throw Error("Unknown history action");
if (
data.expectedRevision !== undefined &&
data.expectedRevision !== store.project.revision
)
throw Error("REVISION_CONFLICT");
store[data.action as "undo" | "redo"]();
json(res, 200, state());
return;
}
if (pathname === "/api/mcp") {
mcpEnabled = Boolean(data.enabled);
if (!mcpEnabled) {
for (const p of pending.values()) {
clearTimeout(p.timer);
p.reject(Error("MCP_DISABLED_BY_USER"));
}
pending.clear();
broadcast("runtime", {
id: uid("cancel"),
action: "cancel",
args: {},
});
}
broadcast("project", state());
json(res, 200, state());
return;
}
if (pathname === "/api/save") {
json(res, 200, await service.save());
return;
}
if (pathname === "/api/runtime-result") {
const p = pending.get(data.id);
if (p) {
clearTimeout(p.timer);
pending.delete(data.id);
if (data.error) p.reject(Error(data.error));
else p.resolve(data.result);
}
json(res, 200, { received: !!p });
return;
}
if (pathname === "/api/runtime" && auth(req)) {
json(res, 200, await service.runtime(data.action, data.args));
return;
}
}
json(res, 404, { error: "Unknown API endpoint" });
return;
}
if (req.method !== "GET" && req.method !== "HEAD") {
json(res, 405, { error: "Method not allowed" });
return;
}
let clean =
pathname === "/" ? "studio/index.html" : pathname.replace(/^\/+/, "");
let bytes: Uint8Array | undefined;
if (clean.startsWith("assets/"))
try {
bytes = await safeRead(projectDir, clean);
} catch (e: any) {
if (e.code !== "ENOENT") throw e;
}
if (!bytes)
try {
bytes = await safeRead(path.join(root, "public"), clean);
} catch (e: any) {
if (e.code === "ENOENT") {
json(res, 404, {
error: "File not found. Run npm run local:build.",
});
return;
}
throw e;
}
res.writeHead(200, {
"content-type": mime[path.extname(clean)] || "application/octet-stream",
"cache-control": "no-cache",
"x-content-type-options": "nosniff",
});
res.end(req.method === "HEAD" ? undefined : bytes);
} catch (e) {
if (!res.headersSent)
json(res, String(e).includes("REVISION_CONFLICT") ? 409 : 400, {
error: String(e),
...(String(e).includes("REVISION_CONFLICT") ? state() : {}),
});
else res.end();
}
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(port, "127.0.0.1", () => {
port = (server.address() as any).port;
resolve();
});
});
await persist();
return {
server,
service,
projectDir,
port,
token,
close: async () => {
await builds.close();
for (const p of pending.values()) {
clearTimeout(p.timer);
p.reject(Error("Server closing"));
}
pending.clear();
for (const r of subscribers) r.end();
subscribers.clear();
await saveQueue.catch(() => {});
await new Promise<void>((resolve) => server.close(() => resolve()));
},
};
}
if (
process.argv[1] &&
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
) {
const value = (f: string) => {
const i = process.argv.indexOf(f);
return i >= 0 ? process.argv[i + 1] : undefined;
};
createService({
projectDir: value("--project"),
port: Number(value("--port") || 4318),
blank: process.argv.includes("--blank"),
editorOrigin: process.env.FORMA_EDITOR_ORIGIN,
})
.then((s) => {
console.log(
"Forma Engine · http://127.0.0.1:" +
s.port +
"\nProject: " +
s.projectDir +
"\nMCP token file: " +
path.join(s.projectDir, ".mcp-token"),
);
const stop = () => void s.close().then(() => process.exit(0));
process.once("SIGINT", stop);
process.once("SIGTERM", stop);
})
.catch((e) => {
console.error(String(e));
process.exitCode = 1;
});
}
+647
View File
@@ -0,0 +1,647 @@
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<any>;
exportWeb: () => Promise<any>;
importModel: (a: any) => Promise<any>;
runtime: (action: string, args?: any) => Promise<any>;
}
export const commandReference = {
transactions:
"11000 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}",
"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",uri?,geometry?,entities?,metadata?}}',
"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: {
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", 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,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 character component; 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)",
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<CallToolResult> => {
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() },
(a) =>
tx(
a,
[
{
op: "scene.create",
args: { name: a.name, ...(a.id ? { id: a.id } : {}) },
},
],
"Создать сцену",
),
);
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_glb",
"Import GLB or embedded glTF using base64 bytes OR a path inside the project folder. External URLs are not fetched.",
{
...revision,
name: z.string().regex(/\.(glb|gltf)$/i),
base64: z.string().max(36_000_000).optional(),
path: z.string().optional(),
instantiate: z.boolean().default(true),
},
(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),
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;
}
+2
View File
@@ -0,0 +1,2 @@
import "tsx/esm";
await import("./stdio.ts");
+55
View File
@@ -0,0 +1,55 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
ListToolsRequestSchema,
CallToolRequestSchema,
ListResourcesRequestSchema,
ReadResourceRequestSchema,
ListPromptsRequestSchema,
GetPromptRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { readFile } from "node:fs/promises";
import path from "node:path";
const value = (f: string) => {
const i = process.argv.indexOf(f);
return i >= 0 ? process.argv[i + 1] : undefined;
};
const projectDir = path.resolve(value("--project") || "projects/MyGame"),
endpoint = value("--url") || "http://127.0.0.1:4318/mcp",
token =
process.env.FORMA_MCP_TOKEN ||
(await readFile(path.join(projectDir, ".mcp-token"), "utf8")).trim();
const client = new Client({ name: "forma-stdio-bridge", version: "0.3.0" });
await client.connect(
new StreamableHTTPClientTransport(new URL(endpoint), {
requestInit: { headers: { Authorization: "Bearer " + token } },
}),
);
const server = new Server(
{ name: "forma-engine", version: "0.3.0" },
{ capabilities: { tools: {}, resources: {}, prompts: {} } },
);
server.setRequestHandler(ListToolsRequestSchema, () => client.listTools());
server.setRequestHandler(CallToolRequestSchema, (r) =>
client.callTool(r.params),
);
server.setRequestHandler(ListResourcesRequestSchema, () =>
client.listResources(),
);
server.setRequestHandler(ReadResourceRequestSchema, (r) =>
client.readResource(r.params),
);
server.setRequestHandler(ListPromptsRequestSchema, () => client.listPrompts());
server.setRequestHandler(GetPromptRequestSchema, (r) =>
client.getPrompt(r.params),
);
await server.connect(new StdioServerTransport());
process.once(
"SIGINT",
() =>
void Promise.all([client.close(), server.close()]).then(() =>
process.exit(0),
),
);