Files
forma-engine/server/mcp.ts
T

648 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}