mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 02:37:01 -06:00
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.
220 lines
10 KiB
Rust
220 lines
10 KiB
Rust
//! 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}));
|
|
}
|