mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
M1: command catalog resource and version-aware MCP results
Co-Authored-By: OpenAI Codex <[email protected]>
This commit is contained in:
1 parent
2eb6b2fd7c
commit
829ec48581
7 files changed
+87
-16
No files matched your search
+1
-1
@@ -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 (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 |
|
||||
| M1 | Selection, transform, layers, MCP (common command tools, strict keys, session resources and version-aware result hints) | ✅ 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 |
|
||||
|
||||
@@ -137,7 +137,7 @@ fn mcp_protocol_over_stdio() {
|
||||
assert!(r["result"]["completion"]["values"].as_array().is_some_and(|v| v.contains(&json!("svg"))), "{r}");
|
||||
|
||||
send(&mut stdin, json!({"jsonrpc":"2.0","id":5,"method":"resources/list","params":{}}));
|
||||
assert!(recv(&mut lines, &mut notes, 5)["result"]["resources"].as_array().is_some_and(|v| v.len() == 2));
|
||||
assert!(recv(&mut lines, &mut notes, 5)["result"]["resources"].as_array().is_some_and(|v| v.len() == 3));
|
||||
send(&mut stdin, json!({"jsonrpc":"2.0","id":6,"method":"resources/templates/list","params":{}}));
|
||||
assert!(recv(&mut lines, &mut notes, 6)["result"]["resourceTemplates"].as_array().is_some_and(|v| v.len() >= 4));
|
||||
|
||||
|
||||
@@ -16,6 +16,9 @@ pub const DOC_URI: &str = "vectorcraft://document";
|
||||
/// The whole document model as JSON.
|
||||
pub const DOC_JSON_URI: &str = "vectorcraft://document/json";
|
||||
|
||||
/// The live command catalog, including enabled state.
|
||||
pub const COMMANDS_URI: &str = "vectorcraft://commands";
|
||||
|
||||
/// Why a resource could not be read. The distinction matters: the server turns it into `-32002`
|
||||
/// (no such resource) or `-32602` (bad URI), and guessing from the message text would not hold.
|
||||
#[derive(Debug)]
|
||||
@@ -79,6 +82,7 @@ pub fn list() -> Value {
|
||||
json!({"resources": [
|
||||
{"uri": DOC_URI, "name": "document", "title": "Active document (summary)", "description": "Layer tree, artboards, selection and history of the active document (document.inspect)", "mimeType": "application/json"},
|
||||
{"uri": DOC_JSON_URI, "name": "document-json", "title": "Active document (full model)", "description": "The complete document model as JSON (document.json)", "mimeType": "application/json"},
|
||||
{"uri": COMMANDS_URI, "name": "commands", "title": "Command catalog", "description": "Command ids, labels, parameter descriptions and current enabled state", "mimeType": "application/json"},
|
||||
]})
|
||||
}
|
||||
|
||||
@@ -104,6 +108,9 @@ pub fn template_source(uri: &str) -> (&str, Option<Live>) {
|
||||
|
||||
/// `resources/read`: the document behind a resource URI.
|
||||
pub fn read(b: &mut dyn Backend, uri: &str) -> Result<Value, ReadError> {
|
||||
if uri == COMMANDS_URI {
|
||||
return b.call("engine.commands", json!({})).map_err(ReadError::Backend);
|
||||
}
|
||||
if uri == DOC_URI {
|
||||
return b.call("document.inspect", json!({})).map_err(ReadError::Backend);
|
||||
}
|
||||
@@ -224,7 +231,7 @@ mod tests {
|
||||
#[test]
|
||||
fn lists_are_well_formed() {
|
||||
let l = list();
|
||||
assert_eq!(l["resources"].as_array().map(Vec::len), Some(2));
|
||||
assert_eq!(l["resources"].as_array().map(Vec::len), Some(3));
|
||||
let t = templates();
|
||||
let items = t["resourceTemplates"].as_array().expect("templates");
|
||||
assert_eq!(items.len(), TEMPLATES.len());
|
||||
|
||||
@@ -11,7 +11,8 @@ use crate::tools::{call_tool, tool_definitions};
|
||||
|
||||
/// The MCP revision we implement.
|
||||
pub const PROTOCOL_VERSION: &str = "2025-06-18";
|
||||
const SUPPORTED_VERSIONS: &[&str] = &[PROTOCOL_VERSION, "2025-03-26", "2024-11-05"];
|
||||
const MODERN_VERSION: &str = "2026-07-28";
|
||||
const SUPPORTED_VERSIONS: &[&str] = &[MODERN_VERSION, PROTOCOL_VERSION, "2025-03-26", "2024-11-05"];
|
||||
|
||||
const PARSE_ERROR: i64 = -32700;
|
||||
const INVALID_REQUEST: i64 = -32600;
|
||||
@@ -22,8 +23,8 @@ const RESOURCE_NOT_FOUND: i64 = -32002;
|
||||
|
||||
const INSTRUCTIONS: &str = "VectorCraft is a professional vector illustration app. Coordinates are \
|
||||
points in document space (y down, origin at the first artboard's top-left; a new document is 612×792). \
|
||||
Draw with draw_shape / draw_path, change colours with set_paint, look with screenshot and inspect_document. \
|
||||
Every menu action is a command: find it with list_commands and run it with run_command. New objects become \
|
||||
Draw with draw_shape / draw_path, change colours with set_paint, look with render_preview and doc_inspect. \
|
||||
Every menu action is a command: find it with command_list and run it with command_run (or command_batch for several). New objects become \
|
||||
the selection, and most commands act on the selection (or on explicit `ids`). prompts/list has ready-made \
|
||||
workflows, completion/complete finishes a command id, a format, an effect or a swatch name, and \
|
||||
vectorcraft://object/{id}, vectorcraft://command/{id}, vectorcraft://effect/{id} and \
|
||||
@@ -33,6 +34,7 @@ vectorcraft://swatch/{name} read one thing at a time instead of the whole docume
|
||||
pub struct Server {
|
||||
backend: Box<dyn Backend>,
|
||||
initialized: bool,
|
||||
modern: bool,
|
||||
/// Whether this server's client asked for log records (`logging/setLevel`). The queue is
|
||||
/// process-wide, so a server whose client didn't ask leaves it to the one that did.
|
||||
logging: bool,
|
||||
@@ -48,7 +50,7 @@ fn error(id: Value, code: i64, message: impl Into<String>) -> Value {
|
||||
|
||||
impl Server {
|
||||
pub fn new(backend: Box<dyn Backend>) -> Self {
|
||||
Self { backend, initialized: false, logging: false }
|
||||
Self { backend, initialized: false, modern: false, logging: false }
|
||||
}
|
||||
|
||||
pub fn backend(&mut self) -> &mut dyn Backend {
|
||||
@@ -136,7 +138,26 @@ impl Server {
|
||||
let r = vectorcraft_engine::guard::catch_panic(|| self.request(method, ¶ms))
|
||||
.unwrap_or_else(|msg| Err((INTERNAL_ERROR, format!("internal error in `{method}`: {msg} (please report this bug)"))));
|
||||
Some(match r {
|
||||
Ok(r) => response(id, r),
|
||||
Ok(mut r) => {
|
||||
let modern = params
|
||||
.get("_meta")
|
||||
.and_then(|m| m.get("io.modelcontextprotocol/protocolVersion"))
|
||||
.and_then(Value::as_str)
|
||||
.map_or(self.modern, |v| v == MODERN_VERSION);
|
||||
if modern && let Some(result) = r.as_object_mut() {
|
||||
let ttl = match method {
|
||||
"tools/list" | "resources/list" | "resources/templates/list" | "prompts/list" => Some(600_000),
|
||||
"resources/read" | "prompts/get" => Some(0),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(ttl) = ttl {
|
||||
result.insert("resultType".into(), json!("complete"));
|
||||
result.insert("ttlMs".into(), json!(ttl));
|
||||
result.insert("cacheScope".into(), json!("private"));
|
||||
}
|
||||
}
|
||||
response(id, r)
|
||||
}
|
||||
Err((code, m)) => error(id, code, m),
|
||||
})
|
||||
}
|
||||
@@ -154,6 +175,7 @@ impl Server {
|
||||
"initialize" => {
|
||||
let asked = params.get("protocolVersion").and_then(Value::as_str).unwrap_or(PROTOCOL_VERSION);
|
||||
let version = if SUPPORTED_VERSIONS.contains(&asked) { asked } else { PROTOCOL_VERSION };
|
||||
self.modern = version == MODERN_VERSION;
|
||||
Ok(json!({
|
||||
"protocolVersion": version,
|
||||
"capabilities": {
|
||||
|
||||
@@ -223,7 +223,7 @@ fn headless_end_to_end() {
|
||||
|
||||
// Resources.
|
||||
let v = rpc(&mut s, 10, "resources/list", json!({}));
|
||||
assert_eq!(v["result"]["resources"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(v["result"]["resources"].as_array().unwrap().len(), 3);
|
||||
let v = rpc(&mut s, 11, "resources/read", json!({"uri": "vectorcraft://document"}));
|
||||
let text = v["result"]["contents"][0]["text"].as_str().unwrap();
|
||||
assert!(serde_json::from_str::<Value>(text).unwrap()["layers"].is_array());
|
||||
|
||||
@@ -90,3 +90,42 @@ fn conventions_tool_panics_are_tool_errors() {
|
||||
assert!(r["content"][0]["text"].as_str().unwrap().contains("backend bug"));
|
||||
assert_eq!(rpc(&mut s, "ping", json!({}))["result"], json!({}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conventions_resources_and_versioned_results() {
|
||||
let mut s = Server::new(Box::new(Headless::with_document()));
|
||||
let modern = json!({"io.modelcontextprotocol/protocolVersion":"2026-07-28"});
|
||||
for (method, extra, ttl) in [
|
||||
("tools/list", json!({}), 600_000),
|
||||
("resources/list", json!({}), 600_000),
|
||||
("resources/templates/list", json!({}), 600_000),
|
||||
("prompts/list", json!({}), 600_000),
|
||||
("resources/read", json!({"uri":"vectorcraft://document"}), 0),
|
||||
("resources/read", json!({"uri":"vectorcraft://commands"}), 0),
|
||||
] {
|
||||
let legacy = rpc(&mut s, method, extra.clone());
|
||||
assert!(legacy["result"].is_object(), "{legacy}");
|
||||
assert!(legacy["result"].get("resultType").is_none());
|
||||
let mut params = extra.clone();
|
||||
params["_meta"] = modern.clone();
|
||||
let r = rpc(&mut s, method, params);
|
||||
assert_eq!(r["result"]["resultType"], "complete", "{r}");
|
||||
assert_eq!(r["result"]["ttlMs"], ttl);
|
||||
assert_eq!(r["result"]["cacheScope"], "private");
|
||||
let mut stripped = r["result"].clone();
|
||||
for key in ["resultType", "ttlMs", "cacheScope"] {
|
||||
stripped.as_object_mut().unwrap().remove(key);
|
||||
}
|
||||
assert_eq!(stripped, legacy["result"]);
|
||||
assert!(rpc(&mut s, method, extra)["result"].get("resultType").is_none());
|
||||
}
|
||||
let r = rpc(&mut s, "resources/list", json!({}));
|
||||
assert!(r["result"]["resources"].as_array().unwrap().iter().any(|r| r["uri"] == "vectorcraft://commands"));
|
||||
let commands = rpc(&mut s, "resources/read", json!({"uri":"vectorcraft://commands"}));
|
||||
let commands: Value = serde_json::from_str(commands["result"]["contents"][0]["text"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(commands, payload(&call(&mut s, "command_list", json!({}))));
|
||||
assert_eq!(rpc(&mut s, "initialize", json!({"protocolVersion":"2026-07-28"}))["result"]["protocolVersion"], "2026-07-28");
|
||||
assert_eq!(rpc(&mut s, "tools/list", json!({}))["result"]["resultType"], "complete");
|
||||
rpc(&mut s, "initialize", json!({"protocolVersion":"2025-06-18"}));
|
||||
assert!(rpc(&mut s, "tools/list", json!({}))["result"].get("resultType").is_none());
|
||||
}
|
||||
+10
-7
@@ -1,7 +1,7 @@
|
||||
# VectorCraft MCP server
|
||||
|
||||
`vectorcraft-cli mcp` runs a [Model Context Protocol](https://modelcontextprotocol.io) server on stdio
|
||||
(newline-delimited JSON-RPC 2.0, protocol `2025-06-18`; `2025-03-26` and `2024-11-05` also accepted). Agents use it to
|
||||
(newline-delimited JSON-RPC 2.0; see [Protocol](#protocol) for version negotiation). Agents use it to
|
||||
draw, inspect and look at VectorCraft documents.
|
||||
|
||||
It has two backends:
|
||||
@@ -35,16 +35,18 @@ For a live session, start the app first: `cargo run --release -p vectorcraft --
|
||||
## Protocol
|
||||
|
||||
Newline-delimited JSON-RPC 2.0 on stdio. The revision is **`2025-06-18`**; `2025-03-26` and `2024-11-05` are
|
||||
accepted too, and `initialize` answers with whichever of those the client asked for. The specs' own
|
||||
`resultType` fields and the `2026-07-28` revision are not implemented — see
|
||||
[Not implemented](#not-implemented).
|
||||
accepted too. Clients requesting `2026-07-28` through `initialize`, or through a request's
|
||||
`_meta["io.modelcontextprotocol/protocolVersion"]`, receive `resultType: "complete"`,
|
||||
`ttlMs` and `cacheScope: "private"` on tool/resource/template/prompt lists and resource/prompt reads.
|
||||
List TTL is 600000 ms; live reads use zero. Older clients keep the original response shapes.
|
||||
This does not add streaming or input requests — see [Not implemented](#not-implemented).
|
||||
|
||||
`initialize` advertises:
|
||||
|
||||
| Capability | What it covers |
|
||||
|---|---|
|
||||
| `tools` | The tools below |
|
||||
| `resources` | Two fixed documents and four templates |
|
||||
| `resources` | Three fixed resources and four templates |
|
||||
| `prompts` | Five ready-made workflows |
|
||||
| `completions` | `completion/complete` for prompt arguments and template variables |
|
||||
| `logging` | `logging/setLevel` and `notifications/message` |
|
||||
@@ -69,8 +71,8 @@ echo '{"jsonrpc":"2.0","id":1,"method":"prompts/get","params":{"name":"poster","
|
||||
|
||||
### Resource templates
|
||||
|
||||
`vectorcraft://document` (summary) and `vectorcraft://document/json` (the whole model) are the fixed
|
||||
resources. The templates read one thing at a time, which matters on a real document: the full model
|
||||
`vectorcraft://document` (summary), `vectorcraft://document/json` (the whole model), and
|
||||
`vectorcraft://commands` (the live command catalog with enabled state) are the fixed resources. The templates read one thing at a time, which matters on a real document: the full model
|
||||
is thousands of lines an agent pays for again on every change.
|
||||
|
||||
| `uriTemplate` | Reads |
|
||||
@@ -176,6 +178,7 @@ argument keys return JSON-RPC `-32602`; command failures and caught tool panics
|
||||
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`.
|
||||
|
||||
Reference in new issue
Block a user