M1: common MCP tools and strict argument handling
Co-Authored-By: OpenAI Codex <noreply@openai.com>
This commit is contained in:
parent
fc74389b5a
commit
2eb6b2fd7c
|
|
@ -171,7 +171,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
|||
| # | Milestone | Status | Est. remaining, one agent (h) |
|
||||
|---|---|---|---|
|
||||
| M0 | Skeleton + vertical slice | ✅ done | — |
|
||||
| M1 | Selection, transform, layers, MCP | ✅ mostly done (transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending) | 4–6 |
|
||||
| M1 | Selection, transform, layers, MCP (common command tools, strict keys, panic recovery and bounded previews) | ✅ mostly done (transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending) | 4–6 |
|
||||
| M2 | Drawing tools + smart guides | ✅ mostly done (Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; construction widget/refinements pending) | 12–18 |
|
||||
| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork; gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool, Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries. Left: freeform and mixed spot/process gradients export as stops or process colours to SVG/PDF; confirm the Scale Strokes & Effects default | 4–8 |
|
||||
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info. Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, polish | 3–6 |
|
||||
|
|
|
|||
|
|
@ -19,8 +19,9 @@ vectorcraft-doc = { workspace = true }
|
|||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
image = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
vectorcraft-testkit = { workspace = true }
|
||||
proptest = { workspace = true }
|
||||
image = { workspace = true }
|
||||
socket2 = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ pub const DEFAULT_ADDR: &str = "127.0.0.1:7979";
|
|||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_conventions;
|
||||
#[cfg(test)]
|
||||
mod tests_distortkeys;
|
||||
#[cfg(test)]
|
||||
mod tests_exportas;
|
||||
|
|
|
|||
|
|
@ -172,6 +172,13 @@ impl Server {
|
|||
"tools/call" => {
|
||||
let name = params.get("name").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing tool `name`".to_string()))?;
|
||||
let args = params.get("arguments").cloned().unwrap_or(Value::Null);
|
||||
if let Some(arguments) = args.as_object()
|
||||
&& let Some(def) = tool_definitions().into_iter().find(|t| t.get("name").and_then(Value::as_str) == Some(name))
|
||||
&& let Some(properties) = def.get("inputSchema").and_then(|s| s.get("properties")).and_then(Value::as_object)
|
||||
&& let Some(key) = arguments.keys().find(|k| !properties.contains_key(*k))
|
||||
{
|
||||
return Err((INVALID_PARAMS, format!("unknown argument `{key}` for `{name}`")));
|
||||
}
|
||||
Ok(call_tool(self.backend.as_mut(), name, &args).to_value())
|
||||
}
|
||||
"resources/list" => Ok(resources::list()),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,92 @@
|
|||
use crate::{Backend, Headless, Server, tool_definitions};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn rpc(s: &mut Server, method: &str, params: Value) -> Value {
|
||||
s.handle(json!({"jsonrpc":"2.0","id":1,"method":method,"params":params})).unwrap()
|
||||
}
|
||||
fn call(s: &mut Server, name: &str, arguments: Value) -> Value {
|
||||
let r = rpc(s, "tools/call", json!({"name":name,"arguments":arguments}));
|
||||
assert!(r.get("error").is_none(), "{r}");
|
||||
r["result"].clone()
|
||||
}
|
||||
fn payload(r: &Value) -> Value {
|
||||
serde_json::from_str(r["content"][0]["text"].as_str().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conventions_catalog_and_strict_keys() {
|
||||
let mut s = Server::new(Box::new(Headless::with_document()));
|
||||
let tools = tool_definitions();
|
||||
for name in [
|
||||
"command_list",
|
||||
"command_run",
|
||||
"command_batch",
|
||||
"doc_inspect",
|
||||
"render_preview",
|
||||
"ui_inspect",
|
||||
"ui_screenshot",
|
||||
"list_commands",
|
||||
"run_command",
|
||||
] {
|
||||
assert!(tools.iter().any(|t| t["name"] == name), "missing {name}");
|
||||
}
|
||||
for t in tools {
|
||||
for hint in ["readOnlyHint", "destructiveHint", "idempotentHint", "openWorldHint"] {
|
||||
assert!(t["annotations"][hint].is_boolean(), "{t}");
|
||||
}
|
||||
let r = rpc(&mut s, "tools/call", json!({"name":t["name"],"arguments":{"typo":1}}));
|
||||
assert_eq!(r["error"]["code"], -32602, "{r}");
|
||||
}
|
||||
assert_eq!(rpc(&mut s, "ping", json!({}))["result"], json!({}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conventions_commands_batch_and_bounded_preview() {
|
||||
let mut s = Server::new(Box::new(Headless::with_document()));
|
||||
let list = call(&mut s, "command_list", json!({"filter":"shape.rectangle","enabled_only":true}));
|
||||
assert_eq!(list["isError"], false, "{list}");
|
||||
assert!(payload(&list).as_array().unwrap().iter().any(|c| c["id"] == "shape.rectangle"));
|
||||
let steps = json!([{"id":"shape.rectangle","params":{"x":10,"y":20,"width":30,"height":40}}, {"id":"no.such.command"}, {"id":"shape.rectangle","params":{"x":0,"y":0,"width":20,"height":20}}]);
|
||||
for (stop, total) in [(true, 2), (false, 3)] {
|
||||
let r = call(&mut s, "command_batch", json!({"steps":steps,"stop_on_error":stop}));
|
||||
assert_eq!(r["isError"], true, "{r}");
|
||||
let p = payload(&r);
|
||||
assert_eq!(p["results"].as_array().unwrap().len(), total);
|
||||
assert_eq!(p["failed"], 1);
|
||||
assert_eq!(p["completed"], total - 1);
|
||||
}
|
||||
let before = call(&mut s, "doc_inspect", json!({}));
|
||||
let r = call(&mut s, "render_preview", json!({"max_side":64}));
|
||||
assert_eq!(r["isError"], false, "{r}");
|
||||
let png = vectorcraft_format::base64_decode(r["content"][0]["data"].as_str().unwrap()).unwrap();
|
||||
let img = image::load_from_memory(&png).unwrap();
|
||||
assert!(img.width() <= 64 && img.height() <= 64);
|
||||
assert_eq!(before, call(&mut s, "doc_inspect", json!({})));
|
||||
for max in [json!(0), json!(4097), json!("64")] {
|
||||
assert_eq!(call(&mut s, "render_preview", json!({"max_side":max}))["isError"], true);
|
||||
}
|
||||
for name in ["ui_inspect", "ui_screenshot"] {
|
||||
assert_eq!(call(&mut s, name, json!({}))["isError"], true);
|
||||
}
|
||||
}
|
||||
|
||||
struct Panics;
|
||||
impl Backend for Panics {
|
||||
fn call(&mut self, _: &str, _: Value) -> Result<Value, String> {
|
||||
panic!("backend bug")
|
||||
}
|
||||
fn has_ui(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn describe(&self) -> String {
|
||||
"test".into()
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn conventions_tool_panics_are_tool_errors() {
|
||||
let mut s = Server::new(Box::new(Panics));
|
||||
let r = call(&mut s, "list_commands", json!({}));
|
||||
assert_eq!(r["isError"], true);
|
||||
assert!(r["content"][0]["text"].as_str().unwrap().contains("backend bug"));
|
||||
assert_eq!(rpc(&mut s, "ping", json!({}))["result"], json!({}));
|
||||
}
|
||||
|
|
@ -51,7 +51,7 @@ fn mods_schema() -> Value {
|
|||
})
|
||||
}
|
||||
fn obj(props: Value, required: &[&str]) -> Value {
|
||||
let mut o = json!({"type": "object", "properties": props});
|
||||
let mut o = json!({"type": "object", "properties": props, "additionalProperties": false});
|
||||
if !required.is_empty() {
|
||||
o["required"] = json!(required);
|
||||
}
|
||||
|
|
@ -64,12 +64,14 @@ fn save_extensions() -> Vec<&'static str> {
|
|||
}
|
||||
|
||||
fn tool(name: &str, title: &str, desc: &str, schema: Value, read_only: bool) -> Value {
|
||||
let writes_file = matches!(name, "screenshot" | "save_file" | "export");
|
||||
let read_only = read_only && !writes_file;
|
||||
json!({
|
||||
"name": name,
|
||||
"title": title,
|
||||
"description": desc,
|
||||
"inputSchema": schema,
|
||||
"annotations": {"title": title, "readOnlyHint": read_only, "openWorldHint": false},
|
||||
"annotations": {"title": title, "readOnlyHint": read_only, "destructiveHint": !read_only, "idempotentHint": read_only, "openWorldHint": false},
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -77,6 +79,52 @@ fn tool(name: &str, title: &str, desc: &str, schema: Value, read_only: bool) ->
|
|||
pub fn tool_definitions() -> Vec<Value> {
|
||||
let empty = || obj(json!({}), &[]);
|
||||
vec![
|
||||
tool(
|
||||
"command_list",
|
||||
"List commands",
|
||||
"Discover command ids, labels, parameter descriptions and enabled state. Returns an array.",
|
||||
obj(json!({"filter": string("Case-insensitive id, label or menu substring"), "enabled_only": {"type":"boolean"}}), &[]),
|
||||
true,
|
||||
),
|
||||
tool(
|
||||
"command_run",
|
||||
"Run a command",
|
||||
"Run an engine command by id with its JSON params. Command parameters follow the registry's descriptions.",
|
||||
obj(json!({"id": string("Command id"), "params": {"type":"object"}}), &["id"]),
|
||||
false,
|
||||
),
|
||||
tool(
|
||||
"command_batch",
|
||||
"Run several commands",
|
||||
"Run steps in order; each edit is its own undo step. Returns completed, failed and results. Stops on the first error by default.",
|
||||
obj(
|
||||
json!({"steps": {"type":"array", "items":obj(json!({"id":string("Command id"),"params":{"type":"object"}}), &["id"])}, "stop_on_error":{"type":"boolean", "default":true}}),
|
||||
&["steps"],
|
||||
),
|
||||
false,
|
||||
),
|
||||
tool(
|
||||
"doc_inspect",
|
||||
"Inspect document",
|
||||
"Summary of the active document, including artboards, layer tree, selection and history.",
|
||||
obj(json!({"depth":{"type":"integer", "minimum":0},"childLimit":{"type":"integer", "minimum":0}}), &[]),
|
||||
true,
|
||||
),
|
||||
tool(
|
||||
"render_preview",
|
||||
"Render a preview",
|
||||
"Render the first artboard as inline PNG without changing the document. max_side defaults to 1024, limited to 1..4096.",
|
||||
obj(json!({"max_side":{"type":"integer","minimum":1,"maximum":4096}}), &[]),
|
||||
true,
|
||||
),
|
||||
tool("ui_inspect", "Inspect the interface", "Inspect the connected desktop interface; returns a tool error in headless mode.", empty(), true),
|
||||
tool(
|
||||
"ui_screenshot",
|
||||
"Capture the interface",
|
||||
"Capture the connected desktop window as inline PNG; requires a running desktop app.",
|
||||
empty(),
|
||||
true,
|
||||
),
|
||||
tool(
|
||||
"list_commands",
|
||||
"List commands",
|
||||
|
|
@ -674,13 +722,24 @@ fn filter_commands(all: Value, a: &Args) -> Value {
|
|||
fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result<ToolResult, String> {
|
||||
let j = |v: Value| Ok(ToolResult::json(&v));
|
||||
match name {
|
||||
"command_list" => {
|
||||
let all = b.call("engine.commands", json!({}))?;
|
||||
let mut filters = a.clone();
|
||||
if let Some(v) = filters.remove("enabled_only") {
|
||||
filters.insert("enabledOnly".into(), v);
|
||||
}
|
||||
j(filter_commands(all, &filters))
|
||||
}
|
||||
"command_batch" => command_batch(b, a),
|
||||
"render_preview" => render_preview(b, a),
|
||||
"ui_screenshot" => screenshot(b, &Map::from_iter([("window".into(), json!(true))])),
|
||||
"list_commands" => {
|
||||
let all = b.call("engine.commands", json!({}))?;
|
||||
let list = filter_commands(all, a);
|
||||
j(json!({"count": list.as_array().map_or(0, Vec::len), "commands": list}))
|
||||
}
|
||||
"run_command" | "invoke_menu" => {
|
||||
let cmd = req_str(a, "command")?;
|
||||
"command_run" | "run_command" | "invoke_menu" => {
|
||||
let cmd = req_str(a, if name == "command_run" { "id" } else { "command" })?;
|
||||
let params = match a.get("params") {
|
||||
None | Some(Value::Null) => json!({}),
|
||||
Some(v @ Value::Object(_)) => v.clone(),
|
||||
|
|
@ -689,13 +748,13 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result<ToolResult, Str
|
|||
let method = if name == "invoke_menu" && b.has_ui() { "ui.menu.invoke" } else { "engine.execute" };
|
||||
j(b.call(method, json!({"command": cmd, "params": params}))?)
|
||||
}
|
||||
"inspect_document" => {
|
||||
"doc_inspect" | "inspect_document" => {
|
||||
// Only the slicing options reach the command, which validates them.
|
||||
let slice: Map<String, Value> =
|
||||
a.iter().filter(|(k, _)| matches!(k.as_str(), "depth" | "childLimit")).map(|(k, v)| (k.clone(), v.clone())).collect();
|
||||
j(b.call("document.inspect", Value::Object(slice))?)
|
||||
}
|
||||
"inspect_ui" => {
|
||||
"ui_inspect" | "inspect_ui" => {
|
||||
need_ui(b, name)?;
|
||||
j(b.call("ui.inspect", json!({}))?)
|
||||
}
|
||||
|
|
@ -826,8 +885,78 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult {
|
|||
Value::Null => &empty,
|
||||
_ => return ToolResult::error("tool arguments must be a JSON object"),
|
||||
};
|
||||
match dispatch(b, name, a) {
|
||||
Ok(r) => r,
|
||||
Err(e) => ToolResult::error(format!("{name}: {e}")),
|
||||
match vectorcraft_engine::guard::catch_panic(|| dispatch(b, name, a)) {
|
||||
Ok(Ok(r)) => r,
|
||||
Ok(Err(e)) => ToolResult::error(format!("{name}: {e}")),
|
||||
Err(e) => ToolResult::error(format!("internal error in `{name}`: {e} (please report this bug)")),
|
||||
}
|
||||
}
|
||||
|
||||
fn command_batch(b: &mut dyn Backend, a: &Args) -> Result<ToolResult, String> {
|
||||
let steps = a.get("steps").and_then(Value::as_array).ok_or("`steps` must be an array")?;
|
||||
let stop = match a.get("stop_on_error") {
|
||||
None => true,
|
||||
Some(v) => v.as_bool().ok_or("`stop_on_error` must be a boolean")?,
|
||||
};
|
||||
let (mut completed, mut failed, mut results) = (0, 0, Vec::new());
|
||||
for step in steps {
|
||||
let result = vectorcraft_engine::guard::catch_panic(|| {
|
||||
let args = step.as_object().ok_or("each step must be an object")?;
|
||||
if args.keys().any(|k| k != "id" && k != "params") {
|
||||
return Err("unknown step argument (expected id, params)".into());
|
||||
}
|
||||
let id = req_str(args, "id")?;
|
||||
let params = match args.get("params") {
|
||||
None | Some(Value::Null) => json!({}),
|
||||
Some(v @ Value::Object(_)) => v.clone(),
|
||||
Some(_) => return Err("`params` must be an object".into()),
|
||||
};
|
||||
b.call("engine.execute", json!({"command":id,"params":params}))
|
||||
})
|
||||
.unwrap_or_else(|e| Err(format!("internal error: {e}")));
|
||||
match result {
|
||||
Ok(v) => {
|
||||
completed += 1;
|
||||
results.push(json!({"ok":true,"result":v}));
|
||||
}
|
||||
Err(e) => {
|
||||
failed += 1;
|
||||
results.push(json!({"ok":false,"error":e}));
|
||||
if stop {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut r = ToolResult::json(&json!({"completed":completed,"failed":failed,"results":results}));
|
||||
r.is_error = failed > 0;
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
fn render_preview(b: &mut dyn Backend, a: &Args) -> Result<ToolResult, String> {
|
||||
let max = match a.get("max_side") {
|
||||
None => 1024,
|
||||
Some(v) => v.as_u64().filter(|v| (1..=4096).contains(v)).ok_or("`max_side` must be an integer in 1..4096")?,
|
||||
};
|
||||
let doc = b.call("document.inspect", json!({"depth":0}))?;
|
||||
let board = doc.get("artboards").and_then(Value::as_array).and_then(|v| v.first()).ok_or("no such artboard")?;
|
||||
let width = board.get("width").and_then(Value::as_f64).ok_or("artboard has no width")?;
|
||||
let height = board.get("height").and_then(Value::as_f64).ok_or("artboard has no height")?;
|
||||
if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 {
|
||||
return Err("invalid artboard dimensions".into());
|
||||
}
|
||||
// The headless renderer clamps scale at 0.01. Bound its allocation even for
|
||||
// a huge artboard, then downsize to the requested dimensions.
|
||||
let scale = (max as f64 / width.max(height)).clamp(0.01, 1.0);
|
||||
if width.max(height) * scale > 4096.0 {
|
||||
return Err("artboard is too large for a bounded preview".into());
|
||||
}
|
||||
let r = b.call("ui.render", json!({"scale":scale}))?;
|
||||
let encoded = r.get("pngBase64").and_then(Value::as_str).ok_or("renderer returned no image")?;
|
||||
let png = vectorcraft_format::base64_decode(encoded).ok_or("renderer returned bad base64")?;
|
||||
let img = image::load_from_memory_with_format(&png, image::ImageFormat::Png).map_err(|e| format!("read preview: {e}"))?;
|
||||
let img = img.thumbnail(max as u32, max as u32);
|
||||
let mut out = std::io::Cursor::new(Vec::new());
|
||||
img.write_to(&mut out, image::ImageFormat::Png).map_err(|e| format!("encode preview: {e}"))?;
|
||||
Ok(image_result(out.get_ref(), json!({"width":img.width(),"height":img.height()})))
|
||||
}
|
||||
|
|
|
|||
28
docs/mcp.md
28
docs/mcp.md
|
|
@ -43,7 +43,7 @@ accepted too, and `initialize` answers with whichever of those the client asked
|
|||
|
||||
| Capability | What it covers |
|
||||
|---|---|
|
||||
| `tools` | The 25 tools below |
|
||||
| `tools` | The tools below |
|
||||
| `resources` | Two fixed documents and four templates |
|
||||
| `prompts` | Five ready-made workflows |
|
||||
| `completions` | `completion/complete` for prompt arguments and template variables |
|
||||
|
|
@ -123,6 +123,12 @@ crate that installed one would override whatever logger an embedder had already
|
|||
`vectorcraft-mcp` directly means calling `vectorcraft_mcp::logging::install()` yourself if you want
|
||||
log records to reach your client.
|
||||
|
||||
### Synchronous calls
|
||||
|
||||
Exports complete synchronously. A `_meta.progressToken` is harmlessly ignored, as are
|
||||
`notifications/cancelled` (including unknown request ids). There are no background export jobs
|
||||
or progress notifications; cancellation cannot interrupt a running call.
|
||||
|
||||
### Not implemented
|
||||
|
||||
Everything below needs the same thing first: the server reads one line at a time and answers it with
|
||||
|
|
@ -150,6 +156,26 @@ the transport — a design change rather than a feature, so it is not in this cr
|
|||
|
||||
## Tools
|
||||
|
||||
### Common command tools
|
||||
|
||||
These names match the conventions in [FilmCraft #28](https://github.com/storytold/filmcraft/pull/28).
|
||||
The older names below remain listed because existing workflows use them.
|
||||
|
||||
| Tool | Arguments and result |
|
||||
|---|---|
|
||||
| `command_list` | Optional `filter`, `enabled_only`; returns the command array |
|
||||
| `command_run` | `id`, optional `params`; runs the command through the existing backend |
|
||||
| `command_batch` | `steps: [{id, params?}]`, optional `stop_on_error` (default true); returns `completed`, `failed`, `results: [{ok, result\|error}]`. Each edit has its own undo step; failures set `isError` |
|
||||
| `doc_inspect` | Optional `depth`, `childLimit`; same summary as `inspect_document` |
|
||||
| `render_preview` | Optional `max_side` (1–4096, default 1024); inline PNG of the first artboard without editing it. Artboards too large to render within the allocation bound return a tool error |
|
||||
| `ui_inspect`, `ui_screenshot` | Connected desktop state/window capture; tool errors in headless mode |
|
||||
|
||||
Every tool declares read-only, destructive, idempotent and open-world hints. File-writing
|
||||
`screenshot`, `save_file` and `export` are conservatively marked as mutations. Unknown top-level
|
||||
argument keys return JSON-RPC `-32602`; command failures and caught tool panics return `isError`
|
||||
content, and the server continues serving. Command `params` are passed through: the registry
|
||||
currently describes them in prose, so MCP does not guess schemas or silently remove keys.
|
||||
|
||||
Coordinates are points in document space: y points down, the origin is the first artboard's top-left, and a new
|
||||
document is 612 × 792 (US Letter). New objects become the selection. Most commands act on the selection or on
|
||||
explicit `ids`.
|
||||
|
|
|
|||
Loading…
Reference in New Issue