//! 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 { 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) -> Value { json!({ "content": [{ "type": "text", "text": msg.into() }], "isError": true }) } fn boxes(ws: &Workspace) -> Map { 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) -> Option { 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, k: &str) -> Option { 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) -> 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::>(), "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 = 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::>() }) }) .collect(); text(json!(list)) } "list_objects" => { let bx = boxes(ws); let t = str_arg(a, "type"); let list: Vec = 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::(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 = 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, String> = ops .into_iter() .enumerate() .map(|(i, v)| serde_json::from_value::(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::>() })) } } } "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::>(), })), } } "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(ws: &Workspace) -> Vec { 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 { 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 })) }