isokit: project layer, Studio GUI, MCP server, viewer, CLI, demo project and docs

This commit is contained in:
grabowski committed 2026-10-22 15:29:07 +07:00
1 parent 95c80ec6b1
commit 564aeb75f6
47 files changed
+5978 -366

No files matched your search

+360
View File
@@ -0,0 +1,360 @@
//! MCP (Model Context Protocol) server: agents edit the scene with the same
//! operations as the Studio. Transport-agnostic: [`handle`] takes one
//! JSON-RPC message and returns the response (None for notifications); the
//! CLI serves it over stdio (`isokit mcp`) and the Studio over HTTP (`/mcp`).
use crate::check::check;
use crate::ops::Op;
use crate::page;
use crate::workspace::Workspace;
use serde_json::{json, Map, Value};
pub const PROTOCOL: &str = "2025-06-18";
const INSTRUCTIONS: &str = "isokit edits an isometric scene (a site plan drawn as a SimCity-style 3D view). \
World coordinates are metres: x east, y north, z up. The scene is a list of objects; each has a type (a prefab), \
a position (x, y = centre of its footprint), a rotation in 90 degree steps and props from the prefab's schema. \
Start with project_info and list_prefabs, then add_object / update_object. Use render_view (summary) to see where \
everything ended up and check_project to find problems. Edits are saved at once and show live in an open Studio.";
fn tool(name: &str, description: &str, props: Value, required: &[&str]) -> Value {
json!({ "name": name, "description": description,
"inputSchema": { "type": "object", "properties": props, "required": required } })
}
pub fn tools() -> Vec<Value> {
let obj_fields = json!({
"x": { "type": "number", "description": "east, metres (centre of the footprint)" },
"y": { "type": "number", "description": "north, metres" },
"z": { "type": "number", "description": "base height, metres (default 0)" },
"rot": { "type": "number", "description": "rotation in degrees, multiples of 90" },
"props": { "type": "object", "description": "prefab properties (see list_prefabs); in updates they merge, null removes" },
"name": { "type": "string", "description": "name shown in the tooltip" },
"info": { "type": "string", "description": "tooltip text" },
"tag": { "type": "string", "description": "label pinned above the object" },
"levels": { "type": "array", "items": { "type": "string" }, "description": "levels it shows on (empty: all)" },
});
let mut add = obj_fields.as_object().unwrap().clone();
add.insert("type".into(), json!({ "type": "string", "description": "prefab type, e.g. building, tree, car (list_prefabs)" }));
add.insert("id".into(), json!({ "type": "string", "description": "wanted id (made unique); default from the type" }));
let mut upd = obj_fields.as_object().unwrap().clone();
upd.insert("id".into(), json!({ "type": "string" }));
upd.insert("hidden".into(), json!({ "type": "boolean" }));
upd.insert(
"type".into(),
json!({ "type": "string", "description": "change the prefab type" }),
);
vec![
tool("project_info", "Title, folder, levels, parameters, object count and load errors of the project.", json!({}), &[]),
tool("list_prefabs", "Object types that can be placed, with their property schemas (type, default, min, max, choices).",
json!({ "category": { "type": "string" }, "type": { "type": "string", "description": "one type in full detail" } }), &[]),
tool("list_objects", "Objects in the scene with position, props and the world box [x0, y0, z0, x1, y1, z1] each one occupies.",
json!({ "type": { "type": "string", "description": "only this type" } }), &[]),
tool("get_object", "One object with all fields and its world box.", json!({ "id": { "type": "string" } }), &["id"]),
tool("add_object", "Place a new object. Returns its id and world box.", Value::Object(add), &["type", "x", "y"]),
tool("update_object", "Change an object: move (x, y, z), turn (rot), edit props, name, info, tag, levels.", Value::Object(upd), &["id"]),
tool("remove_object", "Delete an object.", json!({ "id": { "type": "string" } }), &["id"]),
tool("duplicate_object", "Copy an object, offset by dx, dy metres (default 2, 2).",
json!({ "id": { "type": "string" }, "dx": { "type": "number" }, "dy": { "type": "number" } }), &["id"]),
tool("apply_ops", "Several edits as one undo step. Each op is {op: add|update|remove|duplicate|reorder|set_cfg, ...} \
(add: same fields as add_object; update: {id, patch: {...}}; reorder: {id, to: front|back|index}).",
json!({ "ops": { "type": "array", "items": { "type": "object" } } }), &["ops"]),
tool("set_params", "Set project parameter values (see project_info); null resets to the default.",
json!({ "values": { "type": "object" } }), &["values"]),
tool("render_view", "Render one view. format summary (default): sprite and node counts, warnings, object boxes; \
svg: the standalone SVG markup.",
json!({ "level": { "type": "string" }, "rotation": { "type": "integer", "minimum": 0, "maximum": 3 },
"format": { "type": "string", "enum": ["summary", "svg"] } }), &[]),
tool("check_project", "Lint the project: unknown types, bad props, script warnings, NaN, node budget, unstyled classes.", json!({}), &[]),
tool("build_html", "Write the self-contained HTML page (all views prerendered). Returns the file path.",
json!({ "out": { "type": "string", "description": "path relative to the project (default from isokit.toml)" } }), &[]),
tool("undo", "Undo the last edit.", json!({}), &[]),
tool("redo", "Redo the last undone edit.", json!({}), &[]),
]
}
fn text(v: Value) -> Value {
let t = match v {
Value::String(s) => s,
o => serde_json::to_string_pretty(&o).unwrap_or_default(),
};
json!({ "content": [{ "type": "text", "text": t }], "isError": false })
}
fn tool_error(msg: impl Into<String>) -> Value {
json!({ "content": [{ "type": "text", "text": msg.into() }], "isError": true })
}
fn boxes(ws: &Workspace) -> Map<String, Value> {
let p = &ws.project;
match p.render(&p.manifest.default_level(), 0, None, false) {
Ok(v) => v
.boxes
.into_iter()
.map(|(k, b)| (k, json!(b.map(isokit_core::fmt::round1))))
.collect(),
Err(_) => Map::new(),
}
}
fn object_json(ws: &Workspace, id: &str, bx: &Map<String, Value>) -> Option<Value> {
let o = ws.project.doc.get(id)?;
let mut v = serde_json::to_value(o).ok()?;
if let Some(b) = bx.get(id) {
v["box"] = b.clone();
}
Some(v)
}
fn str_arg(a: &Map<String, Value>, k: &str) -> Option<String> {
a.get(k).and_then(|v| v.as_str()).map(str::to_string)
}
/// Run one tool.
pub fn call_tool(ws: &mut Workspace, name: &str, a: &Map<String, Value>) -> Value {
ws.refresh();
match name {
"project_info" => {
let p = &ws.project;
let m = p.meta();
text(json!({
"title": m.title, "dir": p.dir.display().to_string(), "levels": m.levels, "default_level": m.default_level,
"params": m.params, "values": p.cfg(None), "objects": p.doc.objects.len(),
"types": m.catalog.iter().map(|c| format!("{} ({})", c.kind, c.label)).collect::<Vec<_>>(),
"script_error": p.runtime.as_ref().err().map(|e| e.to_string()),
"load_error": ws.load_error,
"coordinates": "metres; x east, y north, z up; object x, y = centre of its footprint",
}))
}
"list_prefabs" => {
let cat = ws.project.catalog();
if let Some(t) = str_arg(a, "type") {
return match cat.iter().find(|m| m.kind == t) {
Some(m) => text(serde_json::to_value(m).unwrap_or_default()),
None => tool_error(format!("no prefab \"{t}\"")),
};
}
let c = str_arg(a, "category");
let list: Vec<Value> = cat
.iter()
.filter(|m| c.as_ref().is_none_or(|c| m.category.eq_ignore_ascii_case(c)))
.map(|m| {
json!({ "type": m.kind, "label": m.label, "category": m.category, "help": m.help,
"props": m.props.iter().map(|p| {
let mut d = json!({ "key": p.key, "type": p.kind, "default": p.default });
if let Some(x) = p.min { d["min"] = json!(x); }
if let Some(x) = p.max { d["max"] = json!(x); }
if !p.choices.is_empty() { d["choices"] = json!(p.choices); }
if !p.label.is_empty() { d["label"] = json!(p.label); }
d
}).collect::<Vec<_>>() })
})
.collect();
text(json!(list))
}
"list_objects" => {
let bx = boxes(ws);
let t = str_arg(a, "type");
let list: Vec<Value> = ws
.project
.doc
.objects
.iter()
.filter(|o| t.as_ref().is_none_or(|t| &o.kind == t))
.filter_map(|o| object_json(ws, &o.id, &bx))
.collect();
text(json!(list))
}
"get_object" => {
let id = str_arg(a, "id").unwrap_or_default();
let bx = boxes(ws);
match object_json(ws, &id, &bx) {
Some(v) => text(v),
None => tool_error(format!("no object \"{id}\"")),
}
}
"add_object" => {
let mut op = a.clone();
op.insert("op".into(), json!("add"));
match serde_json::from_value::<Op>(Value::Object(op)) {
Err(e) => tool_error(format!("bad arguments: {e}")),
Ok(op) => match ws.apply(op) {
Err(e) => tool_error(e),
Ok(o) => {
let id = o.id.unwrap_or_default();
let bx = boxes(ws);
let warn: Vec<String> = render_warnings(ws)
.into_iter()
.filter(|w| w.contains(&format!("object {id}:")))
.collect();
text(
json!({ "id": id, "object": object_json(ws, &id, &bx), "warnings": warn }),
)
}
},
}
}
"update_object" => {
let id = str_arg(a, "id").unwrap_or_default();
let mut patch = a.clone();
patch.remove("id");
match ws.apply(Op::Update {
id: id.clone(),
patch,
}) {
Err(e) => tool_error(e),
Ok(_) => {
let bx = boxes(ws);
text(json!({ "object": object_json(ws, &id, &bx) }))
}
}
}
"remove_object" => match ws.apply(Op::Remove {
id: str_arg(a, "id").unwrap_or_default(),
}) {
Err(e) => tool_error(e),
Ok(_) => text(json!({ "removed": true, "objects": ws.project.doc.objects.len() })),
},
"duplicate_object" => {
let num = |k: &str| a.get(k).and_then(|v| v.as_f64()).unwrap_or(2.0);
match ws.apply(Op::Duplicate {
id: str_arg(a, "id").unwrap_or_default(),
dx: num("dx"),
dy: num("dy"),
}) {
Err(e) => tool_error(e),
Ok(o) => text(json!({ "id": o.id })),
}
}
"apply_ops" => {
let ops = a
.get("ops")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let parsed: Result<Vec<Op>, String> = ops
.into_iter()
.enumerate()
.map(|(i, v)| serde_json::from_value::<Op>(v).map_err(|e| format!("op {i}: {e}")))
.collect();
match parsed.and_then(|l| ws.apply_all(l)) {
Err(e) => tool_error(e),
Ok(outs) => {
text(json!({ "ids": outs.into_iter().map(|o| o.id).collect::<Vec<_>>() }))
}
}
}
"set_params" => {
let values = a
.get("values")
.and_then(|v| v.as_object())
.cloned()
.unwrap_or_default();
match ws.apply(Op::SetCfg { values }) {
Err(e) => tool_error(e),
Ok(_) => text(json!({ "values": ws.project.cfg(None) })),
}
}
"render_view" => {
let p = &ws.project;
let level = str_arg(a, "level").unwrap_or_else(|| p.manifest.default_level());
let rot = a
.get("rotation")
.and_then(|v| v.as_u64())
.unwrap_or(p.manifest.default_rotation as u64) as u8
% 4;
if str_arg(a, "format").as_deref() == Some("svg") {
return match page::svg(p, &level, rot, false) {
Ok(s) => text(Value::String(s)),
Err(e) => tool_error(e.to_string()),
};
}
match p.render(&level, rot, None, false) {
Err(e) => tool_error(e.to_string()),
Ok(v) => text(json!({
"level": level, "rotation": rot, "sprites": v.sprites, "nodes": v.nodes, "sort_cycles": v.cycles,
"render_ms": v.ms, "warnings": v.warnings,
"boxes": v.boxes.iter().map(|(k, b)| (k.clone(), json!(b.map(isokit_core::fmt::round1)))).collect::<Map<_, _>>(),
})),
}
}
"check_project" => {
let f = check(&ws.project);
text(json!({ "findings": f, "ok": !f.iter().any(|x| x.level == "error") }))
}
"build_html" => {
let p = &ws.project;
let out = str_arg(a, "out")
.or_else(|| p.manifest.build.out.clone())
.unwrap_or_else(|| "dist/index.html".into());
let path = p.dir.join(&out);
match page::static_page(p) {
Err(e) => tool_error(e.to_string()),
Ok(html) => {
if let Some(d) = path.parent() {
let _ = std::fs::create_dir_all(d);
}
match std::fs::write(&path, html) {
Ok(_) => text(json!({ "path": path.display().to_string() })),
Err(e) => tool_error(format!("{}: {e}", path.display())),
}
}
}
}
"undo" => match ws.undo() {
Ok(done) => text(json!({ "undone": done })),
Err(e) => tool_error(e),
},
"redo" => match ws.redo() {
Ok(done) => text(json!({ "redone": done })),
Err(e) => tool_error(e),
},
other => tool_error(format!("unknown tool \"{other}\"")),
}
}
fn render_warnings(ws: &Workspace) -> Vec<String> {
let p = &ws.project;
p.render(&p.manifest.default_level(), 0, None, false)
.map(|v| v.warnings)
.unwrap_or_default()
}
/// Handle one JSON-RPC message.
pub fn handle(ws: &mut Workspace, msg: &Value) -> Option<Value> {
let id = msg.get("id").cloned();
let method = msg.get("method").and_then(|m| m.as_str()).unwrap_or("");
let params = msg.get("params").cloned().unwrap_or(json!({}));
let id = id?; // notifications get no response
let result = match method {
"initialize" => {
let pv = params
.get("protocolVersion")
.and_then(|v| v.as_str())
.unwrap_or(PROTOCOL)
.to_string();
json!({ "protocolVersion": pv, "capabilities": { "tools": { "listChanged": false } },
"serverInfo": { "name": "isokit", "version": env!("CARGO_PKG_VERSION") },
"instructions": INSTRUCTIONS })
}
"ping" => json!({}),
"tools/list" => json!({ "tools": tools() }),
"tools/call" => {
let name = params.get("name").and_then(|v| v.as_str()).unwrap_or("");
let args = params
.get("arguments")
.and_then(|v| v.as_object())
.cloned()
.unwrap_or_default();
call_tool(ws, name, &args)
}
"resources/list" => json!({ "resources": [] }),
"prompts/list" => json!({ "prompts": [] }),
_ => {
return Some(
json!({ "jsonrpc": "2.0", "id": id, "error": { "code": -32601, "message": format!("method not found: {method}") } }),
);
}
};
Some(json!({ "jsonrpc": "2.0", "id": id, "result": result }))
}