Publish Forma Engine 0.3.0 source with documentation and CI
This commit is contained in:
@@ -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
@@ -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
@@ -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:
|
||||
"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}",
|
||||
"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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import "tsx/esm";
|
||||
await import("./stdio.ts");
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
Reference in New Issue
Block a user