From e80b7e383bcf7c8dab1abe363b1c72ee6b852025 Mon Sep 17 00:00:00 2001 From: Fahri Meral Date: Tue, 6 Oct 2026 15:48:28 +0300 Subject: [PATCH] MCP: prompts, resource templates, completions and logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ROADMAP.md | 3 +- apps/vectorcraft-cli/src/main.rs | 3 + crates/mcp/src/lib.rs | 7 + crates/mcp/src/logging.rs | 220 ++++++++++++ crates/mcp/src/prompts.rs | 558 +++++++++++++++++++++++++++++ crates/mcp/src/resources.rs | 265 ++++++++++++++ crates/mcp/src/server.rs | 88 +++-- crates/mcp/src/tests_protocol.rs | 219 +++++++++++ crates/mcp/tests/protocol_props.rs | 231 ++++++++++++ docs/mcp.md | 97 +++++ 10 files changed, 1665 insertions(+), 26 deletions(-) create mode 100644 crates/mcp/src/logging.rs create mode 100644 crates/mcp/src/prompts.rs create mode 100644 crates/mcp/src/resources.rs create mode 100644 crates/mcp/src/tests_protocol.rs diff --git a/ROADMAP.md b/ROADMAP.md index 6ecfefe5..bd578196 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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: diff --git a/apps/vectorcraft-cli/src/main.rs b/apps/vectorcraft-cli/src/main.rs index eb6f472c..25501d4c 100644 --- a/apps/vectorcraft-cli/src/main.rs +++ b/apps/vectorcraft-cli/src/main.rs @@ -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()) diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs index 711c6b09..21b664a4 100644 --- a/crates/mcp/src/lib.rs +++ b/crates/mcp/src/lib.rs @@ -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; diff --git a/crates/mcp/src/logging.rs b/crates/mcp/src/logging.rs new file mode 100644 index 00000000..22d1c6ba --- /dev/null +++ b/crates/mcp/src/logging.rs @@ -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> = 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 { + 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(); + } +} diff --git a/crates/mcp/src/prompts.rs b/crates/mcp/src/prompts.rs new file mode 100644 index 00000000..737fc144 --- /dev/null +++ b/crates/mcp/src/prompts.rs @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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::>() + .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 = PROMPTS + .iter() + .map(|p| { + let arguments: Vec = + 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 { + 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 = 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 { + 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 = + 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 = 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::>()); + 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(), ¶ms); + assert_eq!(v["completion"]["values"], json!([]), "{params}"); + } + } +} diff --git a/crates/mcp/src/resources.rs b/crates/mcp/src/resources.rs new file mode 100644 index 00000000..a834185b --- /dev/null +++ b/crates/mcp/src/resources.rs @@ -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 = 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) { + 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 { + 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 { + 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, 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 { + 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 { + 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}"); + } +} diff --git a/crates/mcp/src/server.rs b/crates/mcp/src/server.rs index c735c279..07df5615 100644 --- a/crates/mcp/src/server.rs +++ b/crates/mcp/src/server.rs @@ -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 { + 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}"))), } } diff --git a/crates/mcp/src/tests_protocol.rs b/crates/mcp/src/tests_protocol.rs new file mode 100644 index 00000000..40d656aa --- /dev/null +++ b/crates/mcp/src/tests_protocol.rs @@ -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) { + 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::(&r).ok()); + let notes: Vec = 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 = 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 ¬es { + 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})); +} diff --git a/crates/mcp/tests/protocol_props.rs b/crates/mcp/tests/protocol_props.rs index 41c948b4..42e5ff92 100644 --- a/crates/mcp/tests/protocol_props.rs +++ b/crates/mcp/tests/protocol_props.rs @@ -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::>()); + 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 { + 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::(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::(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(); diff --git a/docs/mcp.md b/docs/mcp.md index 660d8549..5bb1fe77 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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