- isokit-wasm: wasm-bindgen build of the engine; a project in memory behind the same JSON API as `isokit studio`, plus the MCP tools - isokit-project: Store trait (FsStore folder, MemStore memory) under a generic Workspace; Project::from_files; shared Studio API module (api.rs) used by serve.rs and the WASM build; web_time for clocks in the browser - studio.js runs on a pluggable backend (window.IsoKitBackend); no Studio keys while a dialog is open - web/: projects in IndexedDB, new/demo/delete, zip export/import (stored and deflated), folder import by drop or picker, folders opened live via File System Access with outside edits followed, code editor, page build and preview, two tabs in step, isokit.mcp() and WebMCP registration - tools/build_web.py -> dist/web (static), tools/check_web.py (44 checks)
356 lines
16 KiB
Rust
356 lines
16 KiB
Rust
//! 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::store::Store;
|
|
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<S: Store>(ws: &Workspace<S>) -> 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<S: Store>(ws: &Workspace<S>, 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<S: Store>(ws: &mut Workspace<S>, 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());
|
|
match page::static_page(p) {
|
|
Err(e) => tool_error(e.to_string()),
|
|
Ok(html) => match ws.write_file(&out, &html) {
|
|
Ok(path) => text(json!({ "path": path.display().to_string() })),
|
|
Err(e) => tool_error(e),
|
|
},
|
|
}
|
|
}
|
|
"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<S: Store>(ws: &Workspace<S>) -> 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<S: Store>(ws: &mut Workspace<S>, 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 }))
|
|
}
|