Merge pull request #216 from fahrim/mcp-protocol-surface
MCP: prompts, resource templates, completions and logging
This commit is contained in:
commit
2507ed9dd9
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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(), ¶ms);
|
||||
assert_eq!(v["completion"]["values"], json!([]), "{params}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
|
@ -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}"))),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ¬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}));
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
97
docs/mcp.md
97
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
|
||||
|
|
|
|||
Loading…
Reference in New Issue