MCP: prompts, resource templates, completions and logging

The server answered tools/* and two fixed resources and nothing else, and
resources/templates/list returned an empty array. This adds the rest of the surface a
client can actually use, against 2025-06-18.

- prompts/list and prompts/get: five workflows (poster, icon-set, recolor,
  trace-and-style, export-set) with templated arguments. A missing required argument
  is -32602 and names itself.
- resources/templates/list, and resources/read for the templates: object/{id},
  command/{id}, effect/{id}, swatch/{name}. Reading one thing at a time instead of
  vectorcraft://document/json, which on a real document is thousands of lines an agent
  pays for again on every change.
- completion/complete for prompt arguments and template variables, ranked prefix
  first, capped at 100 with total and hasMore. The values are read live, so a suggested
  format, effect id, command id, trace preset, swatch or object id is one the engine
  has.
- logging/setLevel and notifications/message. Nothing installed a log::Log before, so
  the engine's log::debug! calls went nowhere; this is the sink, silent until asked.
  Installing it is vectorcraft-cli's job rather than the library's, so an embedder
  that already has a logger keeps it.

docs/mcp.md gains a Protocol section with the capability table and a Not implemented
section: subscriptions, progress, cancellation, sampling, elicitation and Streamable
HTTP all need the transport to interleave reads, which this one does not — handle_line
answers a line with a reply, so it neither reads an incoming cancellation nor can write
progress before the reply it belongs to.

62 tests (50 unit, 12 property/fuzz). protocol_props.rs gained five: prompts survive
junk arguments, completion/complete never panics and always answers at most 100
string values with a consistent hasMore, every template reads or fails cleanly, and
prompts/completions/templates are swept with junk params while asserting the document
comes back byte-identical.

ROADMAP: one bullet under Automation in Shipped so far and the changelog line. The
parity table and the gap list are untouched — MCP is agent automation, so feature
parity does not move.
This commit is contained in:
Fahri Meral 2026-10-06 15:48:28 +03:00 committed by bflatastic
parent 597b651bb6
commit e80b7e383b
10 changed files with 1665 additions and 26 deletions

View File

@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob
This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands.
_Last updated: 2026-10-06 (after M4.14–M4.98, M8.1–M8.20 and M14.4–M14.7; see [Honest assessment](#honest-assessment-2026-10-05))._
_Last updated: 2026-10-07 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7 and the MCP prompts, resource templates, completions and logging; see [Honest assessment](#honest-assessment-2026-10-05))._
## Where we are
@ -89,6 +89,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command.
- JSON-lines control channel with real egui pointer and keyboard injection.
- MCP server with 25 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, any command), attached to the running app or headless.
- MCP protocol: five prompts (`prompts/list` / `prompts/get`: poster, icon set, recolour, trace-and-style, export set), `completion/complete` for prompt arguments and template variables read from the live catalogues (formats, effect ids, command ids, trace presets, swatch and object ids), four resource templates (`vectorcraft://object/{id}`, `//command/{id}`, `//effect/{id}`, `//swatch/{name}`) so an agent reads one thing instead of the whole document, and `logging/setLevel` with `notifications/message`. `docs/mcp.md` has a Protocol section covering the capability table and what is deliberately absent (subscriptions, progress, cancellation, sampling, elicitation, Streamable HTTP — all of which need the transport to interleave reads).
- Headless CLI (`vectorcraft-cli run`, `convert`, `info`, `bench`, `perf`, `mcp`).
- Actions panel that records and plays back commands.
- **UI:** Illustrator 2026 layout restyled to measured values:

View File

@ -142,6 +142,9 @@ fn mcp(args: &[String]) -> Result<(), String> {
}
};
eprintln!("vectorcraft-cli: MCP server on stdio ({})", backend.describe());
// The binary owns the logger, not the library: installing one here keeps an embedder that
// uses `vectorcraft_mcp` free to bring its own. Silent until a client sends logging/setLevel.
vectorcraft_mcp::logging::install();
let stdin = std::io::stdin();
let stdout = std::io::stdout();
Server::new(backend).serve(stdin.lock(), stdout.lock()).map_err(|e| e.to_string())

View File

@ -16,11 +16,16 @@
mod backend;
mod headless;
pub mod logging;
mod prompts;
mod resources;
mod server;
mod tools;
pub use backend::{Backend, Remote};
pub use headless::Headless;
pub use prompts::{PROMPTS, PromptArg, PromptDef};
pub use resources::{DOC_JSON_URI, DOC_URI, TEMPLATES};
pub use server::{PROTOCOL_VERSION, Server};
pub use tools::{ToolResult, call_tool, tool_definitions};
@ -48,4 +53,6 @@ mod tests_persp;
#[cfg(test)]
mod tests_place;
#[cfg(test)]
mod tests_protocol;
#[cfg(test)]
mod tests_svg;

220
crates/mcp/src/logging.rs Normal file
View File

@ -0,0 +1,220 @@
//! `logging/setLevel` and the `notifications/message` it turns on.
//!
//! VectorCraft's crates log through the `log` facade, and until now nothing installed a logger, so
//! every `log::debug!` went nowhere. This module is the sink for them: a `log` logger that turns
//! records into MCP `notifications/message`, filtered to the level the client asked for.
//!
//! The **binary** owns the choice of logger and calls [`install`]; the library never does. A
//! library that installed a process-wide logger and set the max level would silence whatever
//! logger an embedder had already set up, without being asked — so `Server::new` only forwards a
//! `logging/setLevel` the client explicitly sent.
//!
//! Nothing is sent before the client asks for it, because MCP has no business pushing log lines at
//! a client that never asked.
use std::sync::Mutex;
use std::sync::atomic::{AtomicU8, Ordering};
use log::{Level, LevelFilter};
use serde_json::{Value, json};
/// The severity from the last `logging/setLevel`, as an index into [`LEVELS`]; [`OFF`] means silent.
static LEVEL: AtomicU8 = AtomicU8::new(OFF);
/// The stored value that means "no logging".
const OFF: u8 = u8::MAX;
/// Records waiting to be drained into the next `notifications/message` batch.
static QUEUE: Mutex<Vec<Value>> = Mutex::new(Vec::new());
/// RFC 5424 severities, from the least to the most severe, as the spec orders them.
const LEVELS: [&str; 8] = ["debug", "info", "notice", "warning", "error", "critical", "alert", "emergency"];
/// The severity a `logging/setLevel` level names, and the `log` level it filters at. `None` for
/// anything the spec doesn't name.
///
/// Four of the eight severities collapse onto `log`'s Error, so the index is kept as well: a
/// client that asked for `emergency` must be able to hear that it is `emergency`, not `error`.
fn parse(name: &str) -> Option<(u8, Level)> {
let level = match name {
"debug" => Level::Debug,
"info" | "notice" => Level::Info,
"warning" => Level::Warn,
"error" | "critical" | "alert" | "emergency" => Level::Error,
_ => return None,
};
Some((LEVELS.iter().position(|l| *l == name)? as u8, level))
}
/// The severity name a `log` record is reported under.
fn severity(level: Level) -> &'static str {
match level {
Level::Trace | Level::Debug => "debug",
Level::Info => "info",
Level::Warn => "warning",
Level::Error => "error",
}
}
/// A `logging/setLevel` level the spec does not name. Nothing was changed.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct UnknownLevel;
/// Turn logging on at `level`, or off with `None`.
///
/// `Err(UnknownLevel)` means the name is not one the spec uses and nothing changed; a rejected
/// level must leave the accepted one in place rather than silencing logging entirely.
pub fn set_level(level: Option<&str>) -> Result<(), UnknownLevel> {
let Some(name) = level else {
LEVEL.store(OFF, Ordering::Relaxed);
log::set_max_level(LevelFilter::Off);
clear();
return Ok(());
};
let Some((index, parsed)) = parse(name) else { return Err(UnknownLevel) };
LEVEL.store(index, Ordering::Relaxed);
log::set_max_level(parsed.to_level_filter());
// Records already queued at the old level are dropped rather than leaked at the new one.
clear();
Ok(())
}
/// The level currently requested, if any.
pub fn level() -> Option<&'static str> {
let index = LEVEL.load(Ordering::Relaxed);
if index == OFF {
return None;
}
LEVELS.get(usize::from(index)).copied()
}
/// Take the queued records, so the server can send them as `notifications/message`.
pub fn drain() -> Vec<Value> {
match QUEUE.lock() {
Ok(mut q) => std::mem::take(&mut *q),
// A poisoned lock only means some other thread panicked while logging; the records in it
// are still readable and worth sending.
Err(poisoned) => std::mem::take(&mut *poisoned.into_inner()),
}
}
/// Forget whatever is queued (used when the client stops listening).
pub fn clear() {
if let Ok(mut q) = QUEUE.lock() {
q.clear();
}
}
/// The `log` logger that feeds [`QUEUE`].
struct Sink;
impl log::Log for Sink {
fn enabled(&self, metadata: &log::Metadata) -> bool {
LEVEL.load(Ordering::Relaxed) != OFF && metadata.level() <= log::max_level()
}
fn log(&self, record: &log::Record) {
if !self.enabled(record.metadata()) {
return;
}
let message = json!({
"level": severity(record.level()),
"logger": record.target(),
"data": record.args().to_string(),
});
// A full queue means the client is not draining; drop rather than grow without bound.
if let Ok(mut q) = QUEUE.lock()
&& q.len() < MAX_QUEUED
{
q.push(message);
}
}
fn flush(&self) {}
}
/// Records kept while the client is behind.
const MAX_QUEUED: usize = 512;
static SINK: Sink = Sink;
/// Install the sink once per process. The binary calls this; the library does not.
///
/// Later calls are ignored: the second `Server` in a test run finds it already there, which is
/// exactly what it wants.
pub fn install() {
// `set_logger` fails only when a logger is already installed; keeping the first one is correct.
let _already_installed = log::set_logger(&SINK);
// Silent until a client sends `logging/setLevel`, so an embedder that installs this sink for
// its own reasons gets no records it did not ask for.
log::set_max_level(LevelFilter::Off);
}
/// The level and the queue are process-wide, so the tests that move them take turns. The server
/// itself is one thread, so nothing else has to.
#[cfg(test)]
pub(crate) fn test_turn() -> std::sync::MutexGuard<'static, ()> {
match TEST_LOCK.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
}
}
#[cfg(test)]
static TEST_LOCK: Mutex<()> = Mutex::new(());
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn levels_round_trip_and_reject_nonsense() {
let _turn = test_turn();
install();
assert_eq!(set_level(Some("warning")), Ok(()));
assert_eq!(level(), Some("warning"));
assert_eq!(set_level(Some("nope")), Err(UnknownLevel), "an unknown level changes nothing");
assert_eq!(level(), Some("warning"), "the old level survives a bad request");
assert_eq!(set_level(Some("emergency")), Ok(()));
assert_eq!(level(), Some("emergency"));
assert_eq!(set_level(None), Ok(()));
assert_eq!(level(), None);
}
#[test]
fn every_spec_level_is_understood() {
let _turn = test_turn();
install();
for name in LEVELS {
assert_eq!(set_level(Some(name)), Ok(()), "{name}");
assert_eq!(level(), Some(name), "{name}");
}
set_level(None).ok();
}
#[test]
fn records_appear_as_messages_only_when_asked_for() {
let _turn = test_turn();
install();
set_level(None).ok();
clear();
log::error!("before logging was enabled");
assert!(drain().is_empty(), "silent until set_level");
set_level(Some("info")).ok();
clear();
log::warn!("hello {name}", name = "world");
// Another test thread may log while logging is on, so look for ours rather than counting.
let got = drain();
let ours = got.iter().find(|m| m["data"] == "hello world").unwrap_or_else(|| panic!("not in {got:?}"));
assert_eq!(ours["level"], "warning");
assert!(ours["logger"].as_str().unwrap_or_default().starts_with("vectorcraft_mcp"), "{ours}");
// Below the requested level stays out.
set_level(Some("error")).ok();
clear();
log::info!("too chatty");
assert!(drain().is_empty());
set_level(None).ok();
}
}

558
crates/mcp/src/prompts.rs Normal file
View File

@ -0,0 +1,558 @@
//! MCP prompts (`prompts/list`, `prompts/get`) and `completion/complete`.
//!
//! A prompt is a reusable VectorCraft workflow: `prompts/get` hands the model one templated user
//! message, so a client can offer "make a poster" the way it offers a slash command. Every prompt's
//! arguments double as completion sources, and the resource templates in [`crate::resources`] share
//! the same [`Live`] catalogues, so `completion/complete` can suggest real command ids, effect ids,
//! swatch names and export formats instead of a fixed list that goes stale.
use serde_json::{Value, json};
use vectorcraft_engine::cmd::fileio::FORMATS;
use crate::backend::Backend;
/// How many values one `completion/complete` reply may carry (the spec's maximum).
const MAX_VALUES: usize = 100;
/// A catalogue the completion values are read from, so they follow the app instead of a copy.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Live {
/// Export formats the engine writes (`document.formats`).
Formats,
/// Image Trace preset names (`imageTrace.presets`).
TracePresets,
/// Live effect ids (`effect.list`).
Effects,
/// Every command id (`engine.commands`).
Commands,
/// Swatch and colour-group names in the document (`swatch.list`).
Swatches,
/// Layer and object ids in the active document (`document.inspect`).
Objects,
}
/// Where one prompt argument's suggestions come from.
#[derive(Clone, Copy)]
pub enum Values {
/// A fixed list, written out here.
Fixed(&'static [&'static str]),
/// The live catalogue.
Live(Live),
}
impl Values {
fn resolve(&self, b: &mut dyn Backend) -> Vec<String> {
match self {
Values::Fixed(v) => v.iter().map(|s| (*s).to_string()).collect(),
Values::Live(live) => live_values(b, *live),
}
}
}
/// The values of one live catalogue, read fresh so a suggestion is never stale.
pub(crate) fn live_values(b: &mut dyn Backend, live: Live) -> Vec<String> {
match live {
Live::Formats => FORMATS.iter().filter(|f| f.write).map(|f| f.id.to_string()).collect(),
Live::TracePresets => names(b, "imageTrace.presets", Some("presets")),
Live::Effects => names(b, "effect.list", Some("catalog")),
// The command catalogue is a control method, not an engine command.
Live::Commands => b.call("engine.commands", json!({})).map(|v| ids_in(&v, None)).unwrap_or_default(),
Live::Swatches => names(b, "swatch.list", Some("swatches")),
Live::Objects => object_ids(b),
}
}
/// The `id` or `name` of every entry a query command returns, optionally under one key.
///
/// These are engine commands, so they go through `engine.execute`; that reaches the same commands
/// in both backends, where a control method the headless session doesn't implement would not.
fn names(b: &mut dyn Backend, command: &str, key: Option<&str>) -> Vec<String> {
b.call("engine.execute", json!({"command": command, "params": {}})).map(|v| ids_in(&v, key)).unwrap_or_default()
}
fn ids_in(reply: &Value, key: Option<&str>) -> Vec<String> {
let items = match key {
Some(k) => reply.get(k).and_then(Value::as_array).map_or(&[][..], Vec::as_slice),
None => reply.as_array().map_or(&[][..], Vec::as_slice),
};
// Catalogs are small, but never trust a document that grew one.
items.iter().take(MAX_VALUES * 8).filter_map(|i| i.get("id").or_else(|| i.get("name"))).filter_map(Value::as_str).map(str::to_string).collect()
}
/// Ids of every layer and object in the active document, for `vectorcraft://object/{id}`.
fn object_ids(b: &mut dyn Backend) -> Vec<String> {
let mut out = Vec::new();
let Ok(v) = b.call("document.inspect", json!({})) else { return out };
let Some(layers) = v.get("layers").and_then(Value::as_array) else { return out };
let mut stack: Vec<&Value> = layers.iter().rev().collect();
while let Some(node) = stack.pop() {
if let Some(id) = node.get("id").and_then(Value::as_i64) {
out.push(id.to_string());
}
if let Some(kids) = node.get("children").and_then(Value::as_array) {
stack.extend(kids.iter().rev());
}
}
out
}
/// One prompt argument.
pub struct PromptArg {
pub name: &'static str,
pub description: &'static str,
pub required: bool,
/// Substituted for `{name}` when the caller leaves the argument out.
pub default: &'static str,
pub values: Values,
}
/// One prompt: a workflow with templated arguments.
pub struct PromptDef {
pub name: &'static str,
pub title: &'static str,
pub description: &'static str,
pub arguments: &'static [PromptArg],
/// The message body; every `{argument}` is replaced with its value.
pub text: &'static str,
}
/// Recolour methods (`recolor.apply`'s `method`), written here because they are a closed set.
const RECOLOR_METHODS: &[&str] = &["exact", "preserveTints", "scaleTints", "tintsShades", "hueShift"];
static NONE: &[&str] = &[];
/// Every prompt, in `prompts/list` order.
pub static PROMPTS: &[PromptDef] = &[
PromptDef {
name: "poster",
title: "Design a poster",
description: "Lay out a poster from a brief: live shapes, gradients, type and live effects, checked with screenshots before export.",
arguments: &[
PromptArg {
name: "brief",
description: "What the poster is for, and the words it must carry",
required: true,
default: "",
values: Values::Fixed(NONE),
},
PromptArg {
name: "palette",
description: "The colours to use, or how to choose them",
required: false,
default: "a limited scheme of three to five colours taken from the brief",
values: Values::Fixed(&[
"a limited scheme of three to five colours taken from the brief",
"a monochrome scheme in one hue, varying value and opacity",
"a two-colour complementary scheme",
"an analogous scheme across neighbouring hues",
]),
},
PromptArg {
name: "text",
description: "The exact headline and supporting lines to set",
required: false,
default: "invent short, concrete wording that fits the brief",
values: Values::Fixed(NONE),
},
],
text: "\
Design a poster in VectorCraft.
Brief: {brief}
Palette: {palette}
Wording: {text}
Coordinates are points in document space: y points down and the origin is the first
artboard's top-left. Work in the document that is already open (inspect_document); only
run file.new when the brief asks for a different artboard size.
Method:
1. Plan the layout before drawing — a hierarchy of shapes, then the type on top.
2. draw_shape for geometry (rectangle with `radius` for corners, ellipse, polygon, star,
line); draw_path for anything with curves, from `points` or from SVG `d`.
3. set_paint for colour. A fill may be \"#rrggbb\", [r,g,b], {c,m,y,k}, {gray} or a whole
paint.setFill params object — pass {\"gradient\": {…}} for a gradient.
4. add_text for type: point type at (x, y), area type with width and height, or mode
\"area\"/\"onPath\" to flow it in or along a path.
5. apply_effect for live effects; call it with no `effect` first to get the catalogue with
each effect's parameters and defaults.
6. screenshot to look at what you made, fix what is wrong, and only then export.
Keep it editable: live shapes, live effects and live text. Do not flatten and do not
rasterise unless the brief asks for it.",
},
PromptDef {
name: "icon-set",
title: "Draw an icon set",
description: "Build a row of consistent icons as live vector shapes on a grid, then check them as a set.",
arguments: &[
PromptArg { name: "subject", description: "What the icons depict", required: true, default: "", values: Values::Fixed(NONE) },
PromptArg {
name: "count",
description: "How many icons in the set",
required: false,
default: "6",
values: Values::Fixed(&["4", "6", "8", "12", "16"]),
},
PromptArg {
name: "detail",
description: "How detailed each icon should be",
required: false,
default: "a single flat silhouette, no strokes",
values: Values::Fixed(&[
"a single flat silhouette, no strokes",
"a filled silhouette with a stroke around it",
"an outlined shape with its strokes only",
"two-tone: a filled shape and a smaller accent shape",
]),
},
],
text: "\
Draw a set of {count} icons of {subject} in VectorCraft, {detail}.
Coordinates are points in document space (y down, origin at the first artboard's
top-left).
Method:
1. Decide the cell size and the gap first, then lay every icon into its own cell so the set
reads as one family: the same stroke weight, the same optical size, the same corner radius.
2. Build each icon from draw_shape and draw_path — live shapes, not strokes traced from
somewhere else. Use `points` with `in`/`out` handles for the curves a silhouette needs.
3. set_paint the whole set the same way, then break one colour out on the object that needs
the accent.
4. screenshot the set and judge it as a set: equal weight, aligned baselines, consistent
padding. Fix what looks off rather than adding more detail.
Return the ids of the icons in a grid, and leave them as separate objects on one layer.",
},
PromptDef {
name: "recolor",
title: "Recolour artwork",
description: "Recolour the selection with explicit rows, or cluster its colours first with recolor.colors.",
arguments: &[
PromptArg {
name: "palette",
description: "The colours to map onto, or 'cluster' to group similar colours first",
required: true,
default: "",
values: Values::Fixed(&[
"cluster",
"a monochrome ramp from light to dark",
"a complementary two-colour scheme",
"one hue per layer, keeping the original values",
]),
},
PromptArg {
name: "method",
description: "How a row's colours take their new colour (recolor.apply's own default is exact; scaleTints suits a recolour driven from recolor.reduce)",
required: false,
default: "exact",
values: Values::Fixed(RECOLOR_METHODS),
},
],
text: "\
Recolour the current selection in VectorCraft with {palette}, using the `{method}` recolour method.
Method:
1. inspect_document to see what is selected and what colours it actually uses.
2. If you do not know the colours, run recolor.colors first: it groups similar colours into
rows (k-means in Lab, weighted by use) and hands back the map. Then edit the `to` colour of
each row and apply it. That is the reliable route for artwork you did not draw.
3. If you know the colours, run recolor.apply directly with `map`: one row per source colour,
`from` as keys or \"#rrggbb\", `to` as a colour. Each new colour keeps the model of the one it
replaces, and gradients, meshes, text and pattern tiles follow.
4. Group the colours with `group` so the result stays editable, and use `limitTo` to snap new
colours to a swatch library.
5. screenshot to check the result against the original intent.
One undo step covers the whole change; gradients and linked swatches stay linked.",
},
PromptDef {
name: "trace-and-style",
title: "Trace an image and style it",
description: "Trace a placed raster with Image Trace, then apply a live effect and paint over the result.",
arguments: &[
PromptArg { name: "path", description: "Path to the image file", required: true, default: "", values: Values::Fixed(NONE) },
PromptArg {
name: "preset",
description: "Image Trace preset to start from",
required: false,
default: "Default",
values: Values::Live(Live::TracePresets),
},
PromptArg {
name: "effect",
description: "A live effect to apply to the traced shapes",
required: false,
default: "stylize.scribble",
values: Values::Live(Live::Effects),
},
],
text: "\
Trace the image at {path} in VectorCraft with the \"{preset}\" preset, then style the result
with the {effect} effect.
Method:
1. open_file the image: it becomes a document of its own pixel size.
2. imageTrace.make with the preset name, adjusting `params` — mode blackAndWhite, grayscale or
color, then threshold, colors, paths, corners, noise, method abutting or overlapping,
ignoreWhite, snapCurvesToLines. Run imageTrace.presets to see every preset with its numbers.
Lower paths and higher corners give the clean, logo-like result; high fidelity needs paths
near 90.
3. apply_effect {effect} on the traced shapes. Call apply_effect with no `effect` for the
catalogue and each effect's parameters.
4. set_paint over the traced shapes to give the artwork its own colours.
5. screenshot, then remove the placed image if it is still underneath.
The trace is real geometry: every traced shape stays selectable and editable.",
},
PromptDef {
name: "export-set",
title: "Export an artboard set",
description: "Export every artboard in several formats at once, with per-format options, and report what each one lost.",
arguments: &[
PromptArg {
name: "formats",
description: "Formats to write, in order",
required: false,
default: "svg, pdf, png",
values: Values::Live(Live::Formats),
},
PromptArg {
name: "directory",
description: "Where the files go",
required: false,
default: "the current directory",
values: Values::Fixed(NONE),
},
PromptArg {
name: "scale",
description: "Raster scale in pixels per point (raster formats only)",
required: false,
default: "2",
values: Values::Fixed(&["1", "2", "3", "4"]),
},
],
text: "\
Export the document in VectorCraft to {formats}, into {directory}, at {scale} pixels per
point for the raster formats.
Method:
1. document.formats lists every format the engine reads and writes, with each one's options and
their defaults. Read it before exporting so the options you pass are real ones.
2. export once per format. A path's extension picks the format; `options` carries the rest, for
example {\"quality\": 80} for jpg. pdf writes one page per artboard — all of them, or
`artboard` (0-based) / `range` (\"1-3, 5\", 1-based); the other formats write one artboard.
3. export without a path to get the bytes back as dataBase64 instead of a file.
4. Report the `warnings` each export returns: they name what that format cannot carry.
Live effects are kept on export (geometry baked; shadows, glows and blur become SVG filters),
and text stays text unless you pass outlineText.",
},
];
/// The message body as a client should receive it.
///
/// Our source wraps these texts at the column limit, which would leave line breaks in the middle
/// of a sentence — and a substituted argument landing on one of them. So each source line is
/// folded into the paragraph it belongs to, while list items keep a line of their own.
fn flow(text: &str) -> String {
text.split("\n\n")
.map(|paragraph| {
let mut out = String::new();
for line in paragraph.lines().map(str::trim).filter(|l| !l.is_empty()) {
if !out.is_empty() {
out.push(if is_item(line) { '\n' } else { ' ' });
}
out.push_str(line);
}
out
})
.collect::<Vec<_>>()
.join("\n\n")
}
/// A numbered or dashed list item, which starts a line instead of continuing one.
fn is_item(line: &str) -> bool {
if let Some(body) = line.strip_prefix("- ") {
return !body.is_empty();
}
line.split_once(". ").is_some_and(|(number, _)| !number.is_empty() && number.len() <= 3 && number.chars().all(|c| c.is_ascii_digit()))
}
/// `prompts/list`.
pub fn list() -> Value {
let prompts: Vec<Value> = PROMPTS
.iter()
.map(|p| {
let arguments: Vec<Value> =
p.arguments.iter().map(|a| json!({"name": a.name, "description": a.description, "required": a.required})).collect();
json!({"name": p.name, "title": p.title, "description": p.description, "arguments": arguments})
})
.collect();
json!({ "prompts": prompts })
}
/// `prompts/get`: the prompt's messages with `{argument}` filled in.
pub fn get(name: &str, args: &Value) -> Result<Value, String> {
let Some(p) = PROMPTS.iter().find(|p| p.name == name) else {
let ids: Vec<&str> = PROMPTS.iter().map(|p| p.name).collect();
return Err(format!("unknown prompt `{name}` ({})", ids.join("|")));
};
let mut text = flow(p.text);
for a in p.arguments {
let given = args.get(a.name).filter(|v| !v.is_null()).map(|v| match v {
Value::String(s) => s.clone(),
other => other.to_string(),
});
let value = match given {
Some(v) if !v.is_empty() => v,
_ if a.required => return Err(format!("prompt `{name}` needs the `{a_name}` argument", a_name = a.name)),
_ => a.default.to_string(),
};
text = text.replace(&format!("{{{}}}", a.name), &value);
}
Ok(json!({
"description": p.description,
"messages": [{"role": "user", "content": {"type": "text", "text": text}}],
}))
}
/// `completion/complete`: values for a prompt argument or a resource-template variable.
///
/// Unknown references and unknown argument names come back empty rather than as an error: a
/// completion is a convenience, and the caller is usually mid-typing.
pub fn complete(b: &mut dyn Backend, params: &Value) -> Value {
let empty = json!({"completion": {"values": [], "total": 0, "hasMore": false}});
let Some(reference) = params.get("ref") else { return empty };
let typed = params.get("argument").and_then(|a| a.get("value")).and_then(Value::as_str).unwrap_or("");
let name = params.get("argument").and_then(|a| a.get("name")).and_then(Value::as_str).unwrap_or("");
let values = match reference.get("type").and_then(Value::as_str) {
Some("ref/prompt") => match reference.get("name").and_then(Value::as_str).and_then(|n| PROMPTS.iter().find(|p| p.name == n)) {
Some(p) => match p.arguments.iter().find(|a| a.name == name) {
Some(a) => a.values.resolve(b),
None => Vec::new(),
},
None => Vec::new(),
},
Some("ref/resource") => match reference.get("uri").and_then(Value::as_str).map(crate::resources::template_source) {
Some((_, Some(live))) => live_values(b, live),
_ => Vec::new(),
},
_ => Vec::new(),
};
// Rank by prefix first (what the user is typing), then by substring.
let needle = typed.to_lowercase();
let mut matches: Vec<String> = values.iter().filter(|v| v.to_lowercase().starts_with(&needle)).cloned().collect();
if matches.is_empty() && !needle.is_empty() {
matches = values.iter().filter(|v| v.to_lowercase().contains(&needle)).cloned().collect();
}
let total = matches.len();
let has_more = total > MAX_VALUES;
matches.truncate(MAX_VALUES);
json!({"completion": {"values": matches, "total": total, "hasMore": has_more}})
}
#[cfg(test)]
mod tests {
use super::*;
fn b() -> Box<dyn Backend> {
Box::new(crate::Headless::with_document())
}
#[test]
fn every_prompt_lists_and_renders() {
let listed = list();
let entries = listed["prompts"].as_array().expect("prompts");
assert_eq!(entries.len(), PROMPTS.len());
for (p, entry) in PROMPTS.iter().zip(entries) {
assert!(!p.name.is_empty() && !p.text.is_empty(), "{}", p.name);
assert_eq!(entry["name"], p.name);
assert_eq!(entry["description"], p.description);
// Arguments are advertised with the names prompts/get will accept.
let names: Vec<&str> = p.arguments.iter().map(|a| a.name).collect();
let listed: Vec<String> =
entry["arguments"].as_array().map_or(Vec::new(), |a| a.iter().filter_map(|x| x["name"].as_str().map(str::to_string)).collect());
let names: Vec<String> = names.into_iter().map(str::to_string).collect();
assert_eq!(names, listed, "{}: listed arguments", p.name);
// Every required argument supplied: the prompt renders.
let args: Value = json!(p.arguments.iter().filter(|a| a.required).map(|a| (a.name, "x")).collect::<std::collections::BTreeMap<_, _>>());
let v = get(p.name, &args).unwrap_or_else(|e| panic!("{} renders: {e}", p.name));
assert_eq!(v["messages"].as_array().map(Vec::len), Some(1));
assert_eq!(v["messages"][0]["role"], "user");
let text = v["messages"][0]["content"]["text"].as_str().unwrap_or_default();
assert!(text.len() > 200, "{}: too short", p.name);
// Templating left nothing behind, and the source's line wrapping is gone.
// Only a declared argument's braces are placeholders; JSON examples carry braces too.
for a in p.arguments {
assert!(!text.contains(&format!("{{{}}}", a.name)), "{}: `{}` left in place", p.name, a.name);
}
assert!(!text.contains('\n') || text.contains("\n\n") || p.arguments.is_empty(), "{}: stray line break", p.name);
assert!(!text.contains(" "), "{}: double space", p.name);
// Only a missing required argument may stop it.
let missing: Value = json!({});
assert_eq!(get(p.name, &missing).is_err(), p.arguments.iter().any(|a| a.required), "{}", p.name);
}
}
#[test]
fn flow_keeps_paragraphs_and_list_items() {
let flowed = flow("first line\nsecond line\n\nnew paragraph\n1. one\n2. two\n- bullet");
assert_eq!(flowed, "first line second line\n\nnew paragraph\n1. one\n2. two\n- bullet");
assert_eq!(flow(""), "");
assert_eq!(flow("no breaks"), "no breaks");
}
#[test]
fn prompt_arguments_are_filled_in() {
let v = get("export-set", &json!({"formats": "pdf, tiff", "scale": "4"})).unwrap();
let text = v["messages"][0]["content"]["text"].as_str().unwrap_or_default();
assert!(text.contains("pdf, tiff"), "{text}");
assert!(text.contains("4 pixels per point"), "{text}");
}
#[test]
fn unknown_prompt_and_missing_argument_are_errors() {
assert!(get("nope", &json!({})).is_err());
let e = get("poster", &json!({"brief": ""})).unwrap_err();
assert!(e.contains("brief"), "{e}");
}
#[test]
fn completions_rank_prefix_first_and_cap_at_100() {
let mut b = b();
let v = complete(
b.as_mut(),
&json!({"ref": {"type": "ref/prompt", "name": "trace-and-style"}, "argument": {"name": "preset", "value": "photo"}}),
);
let values = v["completion"]["values"].as_array().unwrap();
assert!(!values.is_empty(), "imageTrace.presets should answer: {v}");
assert!(values.iter().all(|x| x.as_str().unwrap_or_default().to_lowercase().contains("photo")), "{v}");
// A nonsense prefix falls back to substring matches, and never exceeds 100.
let v = complete(b.as_mut(), &json!({"ref": {"type": "ref/prompt", "name": "export-set"}, "argument": {"name": "formats", "value": ""}}));
let all = v["completion"]["values"].as_array().unwrap().len();
assert!(all > 3, "formats should answer: {v}");
assert!(v["completion"]["total"].as_u64().unwrap() >= all as u64);
}
#[test]
fn unknown_completion_targets_are_empty_not_errors() {
let mut b = b();
for params in [
json!({"ref": {"type": "ref/prompt", "name": "nope"}, "argument": {"name": "x", "value": ""}}),
json!({"ref": {"type": "ref/prompt", "name": "poster"}, "argument": {"name": "nope", "value": ""}}),
json!({"ref": {"type": "ref/resource", "uri": "vectorcraft://nope/{id}"}, "argument": {"name": "id", "value": ""}}),
json!({"ref": {"type": "ref/other"}, "argument": {"name": "x", "value": ""}}),
json!({}),
] {
let v = complete(b.as_mut(), &params);
assert_eq!(v["completion"]["values"], json!([]), "{params}");
}
}
}

265
crates/mcp/src/resources.rs Normal file
View File

@ -0,0 +1,265 @@
//! MCP resources: the fixed documents, the templates an agent can address parts of the
//! document by, and reading either.
//!
//! The templates matter for long sessions. `vectorcraft://document/json` returns the whole model,
//! which on a real document is thousands of lines an agent pays for again on every change; the
//! templates hand out one object, one command definition or one effect's parameters instead, and
//! they are what `completion/complete` suggests ids and names from.
use serde_json::{Value, json};
use crate::backend::Backend;
use crate::prompts::Live;
/// The active document's summary: artboards, layer tree, selection, history.
pub const DOC_URI: &str = "vectorcraft://document";
/// The whole document model as JSON.
pub const DOC_JSON_URI: &str = "vectorcraft://document/json";
/// 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)]
pub enum ReadError {
/// The URI names no resource or template this server serves.
NotFound(String),
/// The template has no value, or the value names nothing.
Invalid(String),
/// The backend could not be asked at all.
Backend(String),
}
/// One addressable template: the URI with `{var}`, and what its variable can be.
pub struct Template {
pub uri: &'static str,
pub name: &'static str,
pub title: &'static str,
pub description: &'static str,
pub mime: &'static str,
pub live: Live,
}
/// Every template, in `resources/templates/list` order.
pub static TEMPLATES: &[Template] = &[
Template {
uri: "vectorcraft://object/{id}",
name: "object",
title: "One layer or object",
description: "The layer or object with this id, with its children, bounds and paint (document.inspect)",
mime: "application/json",
live: Live::Objects,
},
Template {
uri: "vectorcraft://command/{id}",
name: "command",
title: "One command",
description: "The command with this id: label, menu path, shortcut, parameter description and whether it is enabled now (engine.commands)",
mime: "application/json",
live: Live::Commands,
},
Template {
uri: "vectorcraft://effect/{id}",
name: "effect",
title: "One live effect",
description: "The live effect with this id and its parameters with their defaults (effect.list)",
mime: "application/json",
live: Live::Effects,
},
Template {
uri: "vectorcraft://swatch/{name}",
name: "swatch",
title: "One swatch",
description: "The swatch or gradient/pattern swatch with this name (swatch.list)",
mime: "application/json",
live: Live::Swatches,
},
];
/// `resources/list`.
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"},
]})
}
/// `resources/templates/list`.
pub fn templates() -> Value {
let list: Vec<Value> = TEMPLATES
.iter()
.map(|t| json!({"uriTemplate": t.uri, "name": t.name, "title": t.title, "description": t.description, "mimeType": t.mime}))
.collect();
json!({ "resourceTemplates": list })
}
/// Which template a URI is an instance of, and what its variable can be.
pub fn template_source(uri: &str) -> (&str, Option<Live>) {
for t in TEMPLATES {
let Some((prefix, _)) = t.uri.split_once('{') else { continue };
if uri.starts_with(prefix) {
return (t.uri, Some(t.live));
}
}
(uri, None)
}
/// `resources/read`: the document behind a resource URI.
pub fn read(b: &mut dyn Backend, uri: &str) -> Result<Value, ReadError> {
if uri == DOC_URI {
return b.call("document.inspect", json!({})).map_err(ReadError::Backend);
}
if uri == DOC_JSON_URI {
return exec(b, "document.json").map_err(ReadError::Backend);
}
let (template, var) = match TEMPLATES.iter().find(|t| template_source(uri).0 == t.uri) {
Some(t) => (t, variable(uri, t.uri)),
None => return Err(ReadError::NotFound(uri.to_string())),
};
if var.is_empty() {
return Err(ReadError::Invalid(format!("{} needs a value for `{}`", template.uri, variable_name(template.uri))));
}
let found = match template.name {
"object" => find_object(b, &var).map_err(ReadError::Backend)?,
// The command catalogue is a control method; the rest are engine commands.
"command" => find_in(b.call("engine.commands", json!({})).map_err(ReadError::Backend)?, None, "id", &var),
"effect" => find_in(exec(b, "effect.list").map_err(ReadError::Backend)?, Some("catalog"), "id", &var),
"swatch" => find_in(exec(b, "swatch.list").map_err(ReadError::Backend)?, Some("swatches"), "name", &var),
other => return Err(ReadError::NotFound(format!("no reader for template `{other}`"))),
};
Ok(match found {
Some(v) => v,
None => return Err(ReadError::Invalid(format!("no {} named `{var}` in the active document", template.name))),
})
}
/// Run a query engine command. That reaches both backends, where a control method the headless
/// session doesn't implement would not.
fn exec(b: &mut dyn Backend, command: &str) -> Result<Value, String> {
b.call("engine.execute", json!({"command": command, "params": {}}))
}
/// The part of a URI between the template's `{` and its `}`.
fn variable(uri: &str, template: &str) -> String {
let Some(open) = template.find('{') else { return String::new() };
let prefix = &template[..open];
let rest = uri.strip_prefix(prefix).unwrap_or("");
let raw = rest.split('}').next().unwrap_or("");
decode(raw)
}
/// The variable's name, e.g. `id` or `name`.
fn variable_name(template: &str) -> &str {
template.split_once('{').and_then(|(_, r)| r.split_once('}')).map_or("", |(n, _)| n)
}
/// Percent-decoding, so a swatch called "Brand 40%" can be addressed as `Brand%2040%25`.
fn decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while let Some(b) = bytes.get(i).copied() {
if b == b'%'
&& i + 2 < bytes.len()
&& let Ok(byte) = u8::from_str_radix(s.get(i + 1..i + 3).unwrap_or(""), 16)
{
out.push(byte);
i += 3;
continue;
}
out.push(b);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
/// The layer or object with this id in the active document, with its subtree.
fn find_object(b: &mut dyn Backend, id: &str) -> Result<Option<Value>, String> {
let doc = exec(b, "document.inspect")?;
Ok(find_node(doc.get("layers"), id))
}
/// Depth-first search of the layer tree for a node whose `id` matches.
fn find_node(node: Option<&Value>, id: &str) -> Option<Value> {
let nodes = node?.as_array()?;
let mut stack: Vec<&Value> = nodes.iter().rev().collect();
while let Some(n) = stack.pop() {
let hit = match n.get("id") {
Some(Value::String(s)) => s == id,
Some(Value::Number(number)) => number.to_string() == id,
_ => false,
};
if hit {
return Some(n.clone());
}
if let Some(kids) = n.get("children").and_then(Value::as_array) {
stack.extend(kids.iter().rev());
}
}
None
}
/// The item in a query command's array whose `field` equals `value`.
fn find_in(reply: Value, key: Option<&str>, field: &str, value: &str) -> Option<Value> {
let items = match key {
Some(k) => reply.get(k).and_then(Value::as_array).map_or(&[][..], Vec::as_slice),
None => reply.as_array().map_or(&[][..], Vec::as_slice),
};
items.iter().take(MAX_CATALOGUE).find(|i| i.get(field).and_then(Value::as_str) == Some(value)).cloned()
}
/// Catalogs come from the app, but never trust one that grew without bound.
const MAX_CATALOGUE: usize = 20_000;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn variables_are_split_out_and_decoded() {
assert_eq!(variable("vectorcraft://object/42", "vectorcraft://object/{id}"), "42");
assert_eq!(variable("vectorcraft://swatch/Brand%2040%25", "vectorcraft://swatch/{name}"), "Brand 40%");
assert_eq!(variable("vectorcraft://swatch/a%zzb", "vectorcraft://swatch/{name}"), "a%zzb");
assert_eq!(variable("vectorcraft://other", "vectorcraft://object/{id}"), "");
assert_eq!(variable_name("vectorcraft://swatch/{name}"), "name");
assert_eq!(variable_name("no template"), "");
}
#[test]
fn templates_are_addressable() {
assert_eq!(template_source("vectorcraft://object/7"), ("vectorcraft://object/{id}", Some(Live::Objects)));
assert_eq!(template_source("vectorcraft://command/paint.setFill").1, Some(Live::Commands));
assert_eq!(template_source("vectorcraft://document").1, None);
}
#[test]
fn lists_are_well_formed() {
let l = list();
assert_eq!(l["resources"].as_array().map(Vec::len), Some(2));
let t = templates();
let items = t["resourceTemplates"].as_array().expect("templates");
assert_eq!(items.len(), TEMPLATES.len());
for (entry, t) in items.iter().zip(TEMPLATES) {
assert_eq!(entry["uriTemplate"], t.uri);
assert!(t.uri.contains('{'), "{} has no variable", t.uri);
assert!(t.description.len() > 20, "{} needs a description", t.name);
}
}
#[test]
fn missing_and_unknown_uris_are_errors() {
let mut b = Box::new(crate::Headless::with_document());
assert!(read(b.as_mut(), "vectorcraft://nope").is_err());
assert!(read(b.as_mut(), "vectorcraft://object/").is_err());
assert!(read(b.as_mut(), "vectorcraft://object/99999").is_err());
assert!(read(b.as_mut(), DOC_URI).is_ok());
}
#[test]
fn an_object_is_readable_once_it_exists() {
let mut b = Box::new(crate::Headless::with_document());
let made = b.call("engine.execute", json!({"command": "shape.rectangle", "params": {"x": 10, "y": 10, "width": 50, "height": 40}})).unwrap();
let id = made["id"].as_i64().unwrap_or_default().to_string();
let v = read(b.as_mut(), &format!("vectorcraft://object/{id}")).unwrap();
assert_eq!(v["id"].to_string(), id);
assert!(v["bounds"].is_object(), "{v}");
}
}

View File

@ -1,10 +1,12 @@
//! JSON-RPC 2.0 framing and the MCP lifecycle / tools / resources methods.
//! JSON-RPC 2.0 framing and the MCP lifecycle / tools / resources / prompts methods.
use std::io::{BufRead, Write};
use serde_json::{Value, json};
use crate::backend::Backend;
use crate::prompts;
use crate::resources;
use crate::tools::{call_tool, tool_definitions};
/// The MCP revision we implement.
@ -18,15 +20,14 @@ const INVALID_PARAMS: i64 = -32602;
const INTERNAL_ERROR: i64 = -32603;
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 the selection, and most commands act on the \
selection (or on explicit `ids`).";
/// Resource URIs.
pub const DOC_URI: &str = "vectorcraft://document";
pub const DOC_JSON_URI: &str = "vectorcraft://document/json";
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 \
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 \
vectorcraft://swatch/{name} read one thing at a time instead of the whole document.";
/// An MCP server bound to one backend.
pub struct Server {
@ -56,16 +57,30 @@ impl Server {
self.initialized
}
/// The `notifications/message` records queued since the last call.
///
/// Log records belong to no particular request, so these go out after the reply to whatever
/// line produced them rather than before it. [`serve`] drains this itself; a caller driving
/// [`handle_line`] by hand does the same.
pub fn take_notifications(&mut self) -> Vec<String> {
crate::logging::drain()
.into_iter()
.map(|record| json!({"jsonrpc": "2.0", "method": "notifications/message", "params": record}).to_string())
.collect()
}
/// Serve newline-delimited JSON-RPC until `input` closes. Logs go to stderr only (stdout is
/// the protocol stream).
pub fn serve(&mut self, input: impl BufRead, mut output: impl Write) -> std::io::Result<()> {
for line in input.lines() {
let line = line?;
if let Some(reply) = self.handle_line(&line) {
output.write_all(reply.as_bytes())?;
let reply = self.handle_line(&line);
let notes = self.take_notifications();
for line in reply.iter().chain(notes.iter()) {
output.write_all(line.as_bytes())?;
output.write_all(b"\n")?;
output.flush()?;
}
output.flush()?;
}
Ok(())
}
@ -135,7 +150,13 @@ impl Server {
let version = if SUPPORTED_VERSIONS.contains(&asked) { asked } else { PROTOCOL_VERSION };
Ok(json!({
"protocolVersion": version,
"capabilities": {"tools": {}, "resources": {}},
"capabilities": {
"tools": {},
"resources": {},
"prompts": {"listChanged": false},
"completions": {},
"logging": {},
},
"serverInfo": {"name": "vectorcraft", "title": "VectorCraft", "version": env!("CARGO_PKG_VERSION")},
"instructions": format!("{INSTRUCTIONS} Backend: {}.", self.backend.describe()),
}))
@ -147,21 +168,38 @@ impl Server {
let args = params.get("arguments").cloned().unwrap_or(Value::Null);
Ok(call_tool(self.backend.as_mut(), name, &args).to_value())
}
"resources/list" => Ok(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"},
]})),
"resources/templates/list" => Ok(json!({"resourceTemplates": []})),
"resources/list" => Ok(resources::list()),
"resources/templates/list" => Ok(resources::templates()),
"resources/read" => {
let uri = params.get("uri").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing `uri`".to_string()))?;
let v = match uri {
DOC_URI => self.backend.call("document.inspect", json!({})),
DOC_JSON_URI => self.backend.call("engine.execute", json!({"command": "document.json", "params": {}})),
_ => return Err((RESOURCE_NOT_FOUND, format!("resource not found: {uri}"))),
}
.map_err(|e| (INTERNAL_ERROR, e))?;
let v = resources::read(self.backend.as_mut(), uri).map_err(|e| match e {
resources::ReadError::NotFound(uri) => (RESOURCE_NOT_FOUND, format!("resource not found: {uri}")),
resources::ReadError::Invalid(why) => (INVALID_PARAMS, why),
resources::ReadError::Backend(why) => (INTERNAL_ERROR, why),
})?;
Ok(json!({"contents": [{"uri": uri, "mimeType": "application/json", "text": serde_json::to_string_pretty(&v).unwrap_or_default()}]}))
}
"prompts/list" => Ok(prompts::list()),
"prompts/get" => {
let name = params.get("name").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing prompt `name`".to_string()))?;
let args = params.get("arguments").cloned().unwrap_or(Value::Null);
prompts::get(name, &args).map_err(|e| (INVALID_PARAMS, e))
}
"completion/complete" => Ok(prompts::complete(self.backend.as_mut(), params)),
"logging/setLevel" => {
// `level` is required. A null level is our own way of turning logging back off,
// which the spec has no method for and a long session needs.
let Some(asked) = params.get("level") else {
return Err((INVALID_PARAMS, "missing `level`".into()));
};
let level = if asked.is_null() { None } else { asked.as_str() };
crate::logging::set_level(level).map_err(|_| match level {
Some(given) => (INVALID_PARAMS, format!("unknown level `{given}`")),
None => (INVALID_PARAMS, "`level` must be a severity name or null".to_string()),
})?;
log::debug!("client set logging to {}", level.unwrap_or("off"));
Ok(json!({}))
}
other => Err((METHOD_NOT_FOUND, format!("method not found: {other}"))),
}
}

View File

@ -0,0 +1,219 @@
//! The protocol surface beyond tools: prompts, completions, resource templates, subscriptions,
//! logging, progress and cancellation, driven the way a client drives them.
use serde_json::{Value, json};
use crate::{Headless, PROMPTS, Server};
fn server() -> Server {
Server::new(Box::new(Headless::with_document()))
}
/// One request, and whatever the server queued while answering it.
fn rpc(s: &mut Server, id: u64, method: &str, params: Value) -> (Value, Vec<Value>) {
let line = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}).to_string();
let reply = s.handle_line(&line).and_then(|r| serde_json::from_str::<Value>(&r).ok());
let notes: Vec<Value> = s.take_notifications().iter().filter_map(|l| serde_json::from_str(l).ok()).collect();
let reply = reply.unwrap_or(Value::Null);
assert_eq!(reply["jsonrpc"], "2.0", "{reply}");
assert_eq!(reply["id"], id, "{reply}");
(reply, notes)
}
fn result(s: &mut Server, id: u64, method: &str, params: Value) -> Value {
let (v, _) = rpc(s, id, method, params);
assert!(v.get("error").is_none(), "{method}: {v}");
v["result"].clone()
}
/// Run a tool and parse the JSON it answers with.
fn tool(s: &mut Server, id: u64, name: &str, args: Value) -> Value {
let r = result(s, id, "tools/call", json!({"name": name, "arguments": args}));
assert_eq!(r["isError"], false, "{name}: {r}");
let text = r["content"][0]["text"].as_str().unwrap_or("null");
serde_json::from_str(text).unwrap_or(Value::Null)
}
/// Draw a rectangle and return its id.
fn rectangle(s: &mut Server, id: u64) -> i64 {
let made = tool(s, id, "draw_shape", json!({"shape": "rectangle", "x": 10, "y": 10, "width": 60, "height": 40}));
made["id"].as_i64().unwrap_or_default()
}
fn err_code(s: &mut Server, id: u64, method: &str, params: Value) -> i64 {
let (v, _) = rpc(s, id, method, params);
v.get("error").and_then(|e| e["code"].as_i64()).unwrap_or_else(|| panic!("{method} should have failed: {v}"))
}
// ---------- capabilities ----------
#[test]
fn initialize_advertises_what_the_server_actually_answers() {
let mut s = server();
let caps = result(&mut s, 1, "initialize", json!({}))["capabilities"].clone();
assert!(caps["tools"].is_object(), "{caps}");
assert!(caps["resources"].is_object(), "{caps}");
// No `subscribe`: this server cannot observe a change while a call is running, so it does not
// claim to. Advertising a capability that silently misses edits is worse than not having it.
assert!(caps["resources"].get("subscribe").is_none(), "{caps}");
assert!(caps["prompts"].is_object(), "prompts are implemented");
assert!(caps["completions"].is_object(), "completion/complete is implemented");
assert!(caps["logging"].is_object(), "logging/setLevel is implemented");
// And the instructions point at them, since that is where a client looks first.
let instructions = result(&mut s, 2, "initialize", json!({}))["instructions"].as_str().unwrap_or_default().to_string();
assert!(instructions.contains("prompts/list"), "{instructions}");
assert!(instructions.contains("points in document space"), "{instructions}");
}
// ---------- prompts ----------
#[test]
fn prompts_are_listed_and_rendered_over_the_protocol() {
let mut s = server();
let listed = result(&mut s, 1, "prompts/list", json!({}))["prompts"].clone();
let entries = listed.as_array().expect("prompts");
assert_eq!(entries.len(), PROMPTS.len());
for e in entries {
assert!(e["name"].as_str().is_some_and(|n| !n.is_empty()), "{e}");
assert!(e["description"].as_str().is_some_and(|d| d.len() > 20), "{e}");
// Arguments are advertised with the flag prompts/get honours.
for a in e["arguments"].as_array().map_or(&[][..], Vec::as_slice) {
assert!(a["required"].is_boolean(), "{a}");
}
}
let got = result(&mut s, 2, "prompts/get", json!({"name": "poster", "arguments": {"brief": "a jazz festival"}}));
assert_eq!(got["messages"].as_array().map(Vec::len), Some(1));
assert_eq!(got["messages"][0]["role"], "user");
let text = got["messages"][0]["content"]["text"].as_str().unwrap_or_default();
assert!(text.contains("a jazz festival"), "{text}");
}
#[test]
fn prompt_argument_errors_are_invalid_params() {
let mut s = server();
assert_eq!(err_code(&mut s, 1, "prompts/get", json!({"name": "poster"})), -32602);
assert_eq!(err_code(&mut s, 2, "prompts/get", json!({"name": "nope"})), -32602);
assert_eq!(err_code(&mut s, 3, "prompts/get", json!({})), -32602);
}
// ---------- completions ----------
#[test]
fn completions_come_from_the_live_catalogues() {
let mut s = server();
// A prompt argument fed by the export formats.
let v = result(
&mut s,
1,
"completion/complete",
json!({"ref": {"type": "ref/prompt", "name": "export-set"}, "argument": {"name": "formats", "value": "sv"}}),
);
let values: Vec<&str> = v["completion"]["values"].as_array().map_or(Vec::new(), |a| a.iter().filter_map(Value::as_str).collect());
assert!(values.contains(&"svg"), "{v}");
assert_eq!(v["completion"]["hasMore"], false);
// A resource template variable, after something exists to point at.
rectangle(&mut s, 2);
let v = result(
&mut s,
3,
"completion/complete",
json!({"ref": {"type": "ref/resource", "uri": "vectorcraft://object/{id}"}, "argument": {"name": "id", "value": ""}}),
);
let values = v["completion"]["values"].as_array().map_or(0, Vec::len);
assert!(values > 0, "{v}");
assert!(v["completion"]["total"].as_u64().unwrap_or(0) >= values as u64);
}
// ---------- resource templates ----------
#[test]
fn templates_are_listed_and_readable() {
let mut s = server();
let listed = result(&mut s, 1, "resources/templates/list", json!({}));
let templates = listed["resourceTemplates"].as_array().expect("templates");
assert!(templates.len() >= 4, "{listed}");
let id = rectangle(&mut s, 2);
let read = result(&mut s, 3, "resources/read", json!({"uri": format!("vectorcraft://object/{id}")}));
let body: Value = serde_json::from_str(read["contents"][0]["text"].as_str().unwrap_or("{}")).unwrap_or_default();
assert_eq!(body["id"].as_i64(), Some(id), "{body}");
assert!(body["bounds"].is_object(), "{body}");
// A command definition is one small read instead of the whole 400-command catalogue.
let read = result(&mut s, 4, "resources/read", json!({"uri": "vectorcraft://command/paint.setFill"}));
let body: Value = serde_json::from_str(read["contents"][0]["text"].as_str().unwrap_or("{}")).unwrap_or_default();
assert_eq!(body["id"], "paint.setFill", "{body}");
assert!(body["params"].as_str().is_some_and(|p| !p.is_empty()), "{body}");
// An effect's parameters and defaults.
let read = result(&mut s, 5, "resources/read", json!({"uri": "vectorcraft://effect/stylize.dropShadow"}));
let body: Value = serde_json::from_str(read["contents"][0]["text"].as_str().unwrap_or("{}")).unwrap_or_default();
assert_eq!(body["id"], "stylize.dropShadow", "{body}");
// Missing values and unknown resources are distinguishable.
assert_eq!(err_code(&mut s, 6, "resources/read", json!({"uri": "vectorcraft://object/"})), -32602);
assert_eq!(err_code(&mut s, 7, "resources/read", json!({"uri": "vectorcraft://command/nope"})), -32602);
assert_eq!(err_code(&mut s, 8, "resources/read", json!({"uri": "vectorcraft://nope"})), -32002);
}
// ---------- logging ----------
#[test]
fn logging_levels_are_validated_and_off_by_default() {
let _turn = crate::logging::test_turn();
let mut s = server();
assert_eq!(crate::logging::level(), None, "silent until asked for");
result(&mut s, 1, "logging/setLevel", json!({"level": "debug"}));
assert_eq!(crate::logging::level(), Some("debug"));
// A null level turns it back off; a missing one is a bad request.
result(&mut s, 2, "logging/setLevel", json!({"level": null}));
assert_eq!(crate::logging::level(), None);
assert_eq!(err_code(&mut s, 3, "logging/setLevel", json!({"level": "shouty"})), -32602);
assert_eq!(err_code(&mut s, 4, "logging/setLevel", json!({})), -32602);
// A rejected level leaves the accepted one alone.
result(&mut s, 5, "logging/setLevel", json!({"level": "warning"}));
assert_eq!(err_code(&mut s, 6, "logging/setLevel", json!({"level": "shouty"})), -32602);
assert_eq!(crate::logging::level(), Some("warning"));
result(&mut s, 7, "logging/setLevel", json!({"level": null}));
assert_eq!(crate::logging::level(), None);
}
/// Log records reach the client as `notifications/message`, and only after it asked.
#[test]
fn log_records_go_out_as_notifications_once_asked_for() {
let _turn = crate::logging::test_turn();
// The binary installs the logger, not the library, so a test does it for itself.
crate::logging::install();
let mut s = server();
let input = [
json!({"jsonrpc": "2.0", "id": 1, "method": "logging/setLevel", "params": {"level": "debug"}}).to_string(),
json!({"jsonrpc": "2.0", "id": 2, "method": "prompts/list"}).to_string(),
json!({"jsonrpc": "2.0", "id": 3, "method": "logging/setLevel", "params": {"level": null}}).to_string(),
]
.join("\n");
let mut out = Vec::new();
s.serve(input.as_bytes(), &mut out).unwrap();
let lines: Vec<Value> = String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect();
let notes: Vec<&Value> = lines.iter().filter(|l| l["method"] == "notifications/message").collect();
assert!(!notes.is_empty(), "the level change itself is logged: {lines:?}");
for n in &notes {
assert_eq!(n["jsonrpc"], "2.0", "{n}");
assert!(
["debug", "info", "notice", "warning", "error", "critical", "alert", "emergency"]
.contains(&n["params"]["level"].as_str().unwrap_or_default()),
"{n}"
);
assert!(n["params"]["data"].is_string(), "{n}");
}
// Every reply still came before the notifications that followed it.
let answered = lines.iter().filter(|l| l.get("id").is_some()).count();
assert_eq!(answered, 3, "{lines:?}");
let last_reply = lines.iter().rposition(|l| l.get("id").is_some()).unwrap_or(0);
assert!(lines[last_reply + 1..].iter().all(|l| l.get("id").is_none()), "{lines:?}");
result(&mut s, 4, "logging/setLevel", json!({"level": null}));
}

View File

@ -182,6 +182,237 @@ fn resources_are_json() {
}
}
// ---------- prompts, completions, templates, subscriptions ----------
/// Every prompt is well formed and renders whatever it is handed.
#[test]
fn prompts_are_valid_and_render_with_junk_arguments() {
let mut s = server();
let listed = rpc(&mut s, 1, "prompts/list", json!({}));
let prompts = listed["result"]["prompts"].as_array().unwrap();
assert!(!prompts.is_empty());
let mut failures = vec![];
for p in prompts {
let name = p["name"].as_str().unwrap();
assert!(p["description"].as_str().unwrap().len() > 20, "{name}");
let args: Vec<&str> = p["arguments"].as_array().map_or(&[][..], Vec::as_slice).iter().filter_map(|a| a["name"].as_str()).collect();
// Missing, empty, junk and hostile arguments all either render or fail cleanly.
let mut cases = vec![json!({}), Value::Null, json!([]), json!("x")];
for a in &args {
for v in junk_values() {
cases.push(json!({ *a: v }));
cases.push(json!({ *a: { "nested": v.clone() } }));
cases.push(json!({ *a: [v.clone()] }));
}
}
// Filled with every argument, the prompt must render.
let full: Value = json!(args.iter().map(|a| (*a, "value")).collect::<std::collections::BTreeMap<_, _>>());
cases.push(full);
for arguments in cases {
let r = catch_quiet(|| rpc(&mut s, 2, "prompts/get", json!({"name": name, "arguments": arguments.clone()})));
match r {
Err(p) => failures.push(format!("PANIC {name} {arguments}: {p}")),
Ok(v) => {
assert!(v["result"].is_object() || v["error"]["code"] == -32602, "{name} {arguments}: {v}");
// Whatever it returned is valid JSON text in a message.
if let Some(messages) = v["result"]["messages"].as_array() {
for m in messages {
assert!(["user", "assistant"].contains(&m["role"].as_str().unwrap()), "{m}");
assert!(m["content"]["text"].is_string(), "{m}");
}
}
}
}
}
}
assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n"));
}
/// `completion/complete` answers for every prompt argument and never panics on junk.
#[test]
fn completions_are_well_formed_and_survive_junk() {
let mut s = server();
let listed = rpc(&mut s, 1, "prompts/list", json!({}));
let mut cases = vec![];
for p in listed["result"]["prompts"].as_array().unwrap() {
let name = p["name"].as_str().unwrap();
for a in p["arguments"].as_array().map_or(&[][..], Vec::as_slice) {
let arg = a["name"].as_str().unwrap();
for value in ["", "a", "svg", "%20", "..", &"x".repeat(300)] {
cases.push(json!({"ref": {"type": "ref/prompt", "name": name}, "argument": {"name": arg, "value": value}}));
}
}
// An argument the prompt does not have, and a prompt that does not exist.
cases.push(json!({"ref": {"type": "ref/prompt", "name": name}, "argument": {"name": "nope", "value": ""}}));
cases.push(json!({"ref": {"type": "ref/prompt", "name": "nope"}, "argument": {"name": "x", "value": ""}}));
}
for t in templates(&mut s) {
for v in ["", "1", "%", "vectorcraft://x"] {
cases.push(json!({"ref": {"type": "ref/resource", "uri": t["uriTemplate"]}, "argument": {"name": "id", "value": v}}));
}
}
// And malformed references of every shape.
for junk in [json!({}), json!(null), json!([]), json!("x")] {
cases.push(junk);
}
for junk in junk_values().into_iter().take(12) {
cases.push(junk);
}
let mut failures = vec![];
for params in cases {
let r = catch_quiet(|| rpc(&mut s, 2, "completion/complete", params.clone()));
match r {
Err(p) => failures.push(format!("PANIC {params}: {p}")),
Ok(v) => {
assert!(v.get("error").is_none(), "{params}: {v}");
let completion = &v["result"]["completion"];
let values = completion["values"].as_array().unwrap_or_else(|| panic!("{params}: no values"));
assert!(values.len() <= 100, "{params}: {} values", values.len());
assert!(values.iter().all(Value::is_string), "{params}: {v}");
if let Some(total) = completion["total"].as_u64() {
assert!(total >= values.len() as u64, "{params}: {v}");
}
assert_eq!(completion["hasMore"], total_exceeds(completion), "{params}: {v}");
}
}
}
assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n"));
}
/// `hasMore` is set exactly when the catalogue held more than we sent.
fn total_exceeds(completion: &Value) -> Value {
let sent = completion["values"].as_array().map_or(0, Vec::len);
json!(completion["total"].as_u64().is_some_and(|t| t > sent as u64))
}
fn templates(s: &mut Server) -> Vec<Value> {
rpc(s, 9, "resources/templates/list", json!({}))["result"]["resourceTemplates"].as_array().unwrap().clone()
}
/// Every template lists with a variable, and reading it answers JSON or a clean error.
#[test]
fn resource_templates_are_addressable() {
let mut s = server();
let mut failures = vec![];
for t in templates(&mut s) {
let uri = t["uriTemplate"].as_str().unwrap();
assert!(uri.contains('{') && uri.ends_with('}'), "{t}");
assert!(t["description"].as_str().unwrap().len() > 20, "{t}");
assert!(t["mimeType"].is_string(), "{t}");
// A template with its variable left empty, filled with junk, and with a real value.
let prefix = uri.split('{').next().unwrap();
let mut uris = vec![prefix.to_string(), format!("{prefix}{{"), format!("{prefix}%20"), format!("{prefix}-1")];
let made = call(&mut s, "draw_shape", json!({"shape": "rectangle", "x": 1, "y": 1, "width": 20, "height": 20}));
if let Some(id) =
made["result"]["content"][0]["text"].as_str().and_then(|t| serde_json::from_str::<Value>(t).ok()).and_then(|v| v["id"].as_i64())
{
uris.push(format!("{prefix}{id}"));
}
for uri in uris {
let r = catch_quiet(|| rpc(&mut s, 10, "resources/read", json!({"uri": uri.clone()})));
match r {
Err(p) => failures.push(format!("PANIC {uri}: {p}")),
Ok(v) => {
if let Some(text) = v["result"]["contents"][0]["text"].as_str() {
serde_json::from_str::<Value>(text).unwrap_or_else(|e| panic!("{uri} is not JSON: {e}"));
} else {
let code = v["error"]["code"].as_i64().unwrap_or_default();
assert!(code == -32602 || code == -32002, "{uri}: {v}");
}
}
}
}
}
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
/// Anything a client may send for the prompts, completion, logging and template methods leaves the
/// server answering and the document valid.
#[test]
fn the_new_methods_survive_junk_and_keep_the_document_valid() {
let mut failures = vec![];
let mut s = server();
rpc(&mut s, 1, "tools/call", json!({"name": "draw_shape", "arguments": {"shape": "rectangle", "x": 0, "y": 0, "width": 30, "height": 20}}));
let before = doc_json(&document(&mut s));
let mut cases: Vec<(&str, Value)> = vec![];
for uri in ["vectorcraft://document", "vectorcraft://object/1", "vectorcraft://nope", "", "x://y", "%"] {
cases.push(("resources/read", json!({"uri": uri})));
cases.push(("resources/read", json!({})));
cases.push(("resources/read", json!({"uri": 5})));
}
for level in [json!("debug"), json!("emergency"), json!(null), json!("shouty"), json!(5), json!([]), json!({})] {
cases.push(("logging/setLevel", json!({"level": level})));
}
cases.push(("logging/setLevel", json!({})));
for name in [json!("poster"), json!("nope"), json!(""), json!(5), json!(null), json!([])] {
cases.push(("prompts/get", json!({"name": name})));
cases.push(("prompts/get", json!({"name": name, "arguments": []})));
}
for ref_ in [
json!({"type": "ref/prompt", "name": "poster"}),
json!({"type": "ref/resource", "uri": "vectorcraft://object/{id}"}),
json!({"type": "ref/other"}),
json!("x"),
json!([]),
] {
cases.push(("completion/complete", json!({"ref": ref_, "argument": {"name": "brief", "value": ""}})));
cases.push(("completion/complete", json!({"ref": ref_})));
}
for (method, params) in cases {
let r = catch_quiet(|| rpc(&mut s, 11, method, params.clone()));
match r {
Err(p) => failures.push(format!("PANIC {method} {params}: {p}")),
Ok(v) => {
assert!(v.get("result").is_some() != v.get("error").is_some(), "{method} {params}: {v}");
// Whatever it answered, every notification it queued is a JSON-RPC message.
for line in s.take_notifications() {
let n: Value = serde_json::from_str(&line).unwrap_or_else(|e| panic!("bad notification {line}: {e}"));
assert_eq!(n["jsonrpc"], "2.0", "{n}");
assert_eq!(n["method"], "notifications/message", "{n}");
assert!(n["params"]["level"].is_string() && n["params"]["data"].is_string(), "{n}");
}
check_document(&document(&mut s)).unwrap_or_else(|e| failures.push(format!("{method} {params}: {e}")));
}
}
}
// None of these edit the document, so it must be exactly as it was.
assert_eq!(doc_json(&document(&mut s)), before, "a read-only method changed the document");
assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n"));
}
/// Every capability the server advertises is one it actually answers.
#[test]
fn advertised_capabilities_all_answer() {
let mut s = server();
let caps = rpc(&mut s, 1, "initialize", json!({}))["result"]["capabilities"].clone();
let checks: Vec<(&str, &str, Value)> = vec![
("tools", "tools/list", json!({})),
("prompts", "prompts/list", json!({})),
("completions", "completion/complete", json!({"ref": {"type": "ref/prompt", "name": "poster"}, "argument": {"name": "brief", "value": ""}})),
("logging", "logging/setLevel", json!({"level": null})),
("resources", "resources/list", json!({})),
("resources", "resources/templates/list", json!({})),
("resources", "resources/read", json!({"uri": doc_uri_json(&mut s)})),
];
for (capability, method, params) in checks {
assert!(caps[capability].is_object(), "{capability} is not advertised: {caps}");
assert!(rpc(&mut s, 2, method, params).get("error").is_none(), "{method} is advertised but does not answer");
}
// And nothing is advertised that the server does not answer.
assert!(caps["resources"].get("subscribe").is_none(), "subscribe is advertised but not implemented: {caps}");
assert_eq!(err_code(&mut s, 3, "resources/subscribe", json!({"uri": "vectorcraft://document"})), -32601);
}
/// A capability the server advertises must be one it answers, and one it does not advertise must
/// not be there.
fn err_code(s: &mut Server, id: u64, method: &str, params: Value) -> i64 {
let v = rpc(s, id, method, params);
v.get("error").and_then(|e| e["code"].as_i64()).unwrap_or_else(|| panic!("{method} should have failed: {v}"))
}
#[test]
fn undo_redo_tools_restore_documents() {
let mut s = server();

View File

@ -32,6 +32,103 @@ Other clients use the same command in their JSON config:
For a live session, start the app first: `cargo run --release -p vectorcraft -- --control 7979`.
## 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).
`initialize` advertises:
| Capability | What it covers |
|---|---|
| `tools` | The 25 tools below |
| `resources` | Two fixed documents and four templates |
| `prompts` | Five ready-made workflows |
| `completions` | `completion/complete` for prompt arguments and template variables |
| `logging` | `logging/setLevel` and `notifications/message` |
### Prompts
`prompts/list` offers five workflows; `prompts/get` returns one templated user message with its
arguments filled in. A missing required argument is `-32602`.
| Prompt | Arguments (required first) |
|---|---|
| `poster` | `brief`, `palette`, `text` |
| `icon-set` | `subject`, `count`, `detail` |
| `recolor` | `palette`, `method` |
| `trace-and-style` | `path`, `preset`, `effect` |
| `export-set` | `formats`, `directory`, `scale` |
```sh
echo '{"jsonrpc":"2.0","id":1,"method":"prompts/get","params":{"name":"poster","arguments":{"brief":"a jazz festival"}}}' \
| vectorcraft-cli mcp --headless
```
### 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
is thousands of lines an agent pays for again on every change.
| `uriTemplate` | Reads |
|---|---|
| `vectorcraft://object/{id}` | One layer or object with its children, bounds and paint |
| `vectorcraft://command/{id}` | One command: label, menu path, shortcut, parameter description, enablement |
| `vectorcraft://effect/{id}` | One live effect with its parameters and defaults |
| `vectorcraft://swatch/{name}` | One swatch, colour, gradient or pattern swatch (percent-encode spaces) |
An unknown URI is `-32002`; a template with no value, or a value that names nothing, is `-32602`.
### Completions
`completion/complete` answers for a prompt argument (`ref/prompt`) or a template variable
(`ref/resource`), ranked by prefix and then by substring, capped at 100 values with `total` and
`hasMore`. The values are read from the live catalogues, so they follow the app: `formats` suggests
what the engine writes, `preset` the Image Trace presets, `effect` the effect ids, `{id}` the layer
and object ids in the document, `{name}` its swatch names. An unknown reference comes back empty
rather than as an error, since a completion is asked for mid-typing.
### Logging
`logging/setLevel` accepts any RFC 5424 severity — `debug`, `info`, `notice`, `warning`, `error`,
`critical`, `alert`, `emergency` — and an unknown one is `-32602` with the previous level left in
place. From then on the engine's own `log` records arrive as `notifications/message` with `level`,
`logger` and `data`. A `null` level turns it back off. Nothing is sent before the client asks, and
records queued at the old level are dropped rather than replayed at the new one.
Installing the logger that receives those records is `vectorcraft-cli`'s job, not the library's: a
crate that installed one would override whatever logger an embedder had already set up. Embedding
`vectorcraft-mcp` directly means calling `vectorcraft_mcp::logging::install()` yourself if you want
log records to reach your client.
### Not implemented
Everything below needs the same thing first: the server reads one line at a time and answers it with
one reply, so **while a `tools/call` is running it neither reads an incoming line nor writes an
outgoing one.** Progress and cancellation are therefore both meaningless as they stand, and a
subscription could only ever be checked between calls — which is not what a client asking to be told
about a change expects.
- **`resources/subscribe` / `notifications/resources/updated`.** It would also have to be honest about
*whose* edits it sees: comparing the undo history's length misses edits made in the app window,
which is the main reason to run against a live document. `document.inspect`'s `revision` is the
right signal — it is monotonic and already bumped when a document is replaced — but noticing an
edit still means someone asking.
- **`notifications/progress`.** It would have to be written *before* the reply it belongs to, since a
progress token stops meaning anything once the request has completed.
- **`notifications/cancelled`.** It would have to be read *during* the call it cancels.
- **`sampling/createMessage` and `elicitation/create`.** Both are server→client requests, blocked by
the same one-line-one-reply loop. Supporting them means being able to suspend a tool call
mid-flight, which is what the `2026-07-28` revision's input-requests mechanism exists for.
- **Streamable HTTP / SSE.** stdio only.
`completion/complete` covers the "what could I pass here" half of the same problem, which is why it
is here and subscriptions are not. The fix for the rest is a reader thread (or an event loop) around
the transport — a design change rather than a feature, so it is not in this crate yet.
## Tools
Coordinates are points in document space: y points down, the origin is the first artboard's top-left, and a new