Publish Forma Engine 0.3.0 source with documentation and CI
This commit is contained in:
+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;
|
||||
}
|
||||
Reference in New Issue
Block a user