isokit: project layer, Studio GUI, MCP server, viewer, CLI, demo project and docs
This commit is contained in:
1 parent
95c80ec6b1
commit
564aeb75f6
47 files changed
+5978
-366
No files matched your search
@@ -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 }))
|
||||
}
|
||||
Reference in new issue
Block a user