vectorcraft/crates/mcp/tests/protocol_props.rs

272 lines
12 KiB
Rust

//! MCP server properties (in-process, headless backend): tool schemas are valid, every tool
//! survives junk arguments, JSON-RPC framing always answers correctly, and random editing through
//! MCP keeps the document valid.
use drawcraft_doc::Document;
use drawcraft_mcp::{Headless, Server, tool_definitions};
use drawcraft_testkit::catch_quiet;
use drawcraft_testkit::invariants::{check_document, doc_json};
use drawcraft_testkit::strategies::{arb_ops, junk_values};
use proptest::prelude::*;
use serde_json::{Value, json};
fn server() -> Server {
Server::new(Box::new(Headless::with_document()))
}
fn rpc(s: &mut Server, id: u64, method: &str, params: Value) -> Value {
let line = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}).to_string();
let reply = s.handle_line(&line).expect("requests get a reply");
let v: Value = serde_json::from_str(&reply).expect("reply is JSON");
assert_eq!(v["jsonrpc"], "2.0");
assert_eq!(v["id"], id);
assert!(v.get("result").is_some() != v.get("error").is_some(), "exactly one of result/error: {v}");
v
}
fn call(s: &mut Server, name: &str, args: Value) -> Value {
rpc(s, 1, "tools/call", json!({"name": name, "arguments": args}))
}
fn document(s: &mut Server) -> Document {
let v = rpc(s, 2, "resources/read", json!({"uri": "drawcraft://document/json"}));
let text = v["result"]["contents"][0]["text"].as_str().unwrap_or_else(|| panic!("{v}"));
serde_json::from_str(text).expect("document JSON")
}
fn doc_uri_json(s: &mut Server) -> String {
let list = rpc(s, 3, "resources/list", json!({}));
list["result"]["resources"].as_array().unwrap().iter().find(|r| r["name"] == "document-json").unwrap()["uri"].as_str().unwrap().to_string()
}
/// Minimal JSON-schema sanity: an object schema with typed properties and consistent `required`.
fn validate_schema(name: &str, schema: &Value) {
assert_eq!(schema["type"], "object", "{name}: inputSchema.type");
let props = schema.get("properties").and_then(Value::as_object).unwrap_or_else(|| panic!("{name}: properties must be an object"));
for (k, p) in props {
let p = p.as_object().unwrap_or_else(|| panic!("{name}.{k}: property schema must be an object"));
assert!(
p.contains_key("type") || p.contains_key("anyOf") || p.contains_key("oneOf") || p.contains_key("enum") || p.contains_key("$ref"),
"{name}.{k}: property has no type"
);
if let Some(t) = p.get("type") {
let ok = |t: &str| ["object", "array", "string", "number", "integer", "boolean", "null"].contains(&t);
match t {
Value::String(t) => assert!(ok(t), "{name}.{k}: bad type {t}"),
Value::Array(ts) => assert!(ts.iter().all(|t| t.as_str().is_some_and(ok)), "{name}.{k}: bad types"),
_ => panic!("{name}.{k}: type must be a string"),
}
if t == "array" {
assert!(p.get("items").is_none_or(Value::is_object), "{name}.{k}: items must be a schema");
}
if t == "object" {
assert!(p.get("properties").is_none_or(Value::is_object), "{name}.{k}: nested properties");
}
}
if let Some(e) = p.get("enum") {
assert!(e.as_array().is_some_and(|a| !a.is_empty()), "{name}.{k}: empty enum");
}
}
if let Some(req) = schema.get("required") {
for r in req.as_array().unwrap_or_else(|| panic!("{name}: required must be an array")) {
let r = r.as_str().unwrap();
assert!(props.contains_key(r), "{name}: required `{r}` is not a property");
}
}
}
#[test]
fn tool_definitions_are_valid() {
let tools = tool_definitions();
assert!(tools.len() >= 15);
let mut names = std::collections::HashSet::new();
for t in &tools {
let name = t["name"].as_str().expect("name");
assert!(names.insert(name.to_string()), "duplicate tool {name}");
assert!(name.chars().all(|c| c.is_ascii_lowercase() || c == '_'), "tool name {name}");
assert!(t["description"].as_str().is_some_and(|d| d.len() >= 10), "{name}: description");
assert!(t["annotations"]["readOnlyHint"].is_boolean(), "{name}: readOnlyHint");
validate_schema(name, &t["inputSchema"]);
}
// tools/list over the protocol returns the same list.
let mut s = server();
let v = rpc(&mut s, 1, "tools/list", json!({}));
assert_eq!(v["result"]["tools"].as_array().unwrap().len(), tools.len());
}
#[test]
fn every_tool_survives_empty_and_junk_arguments() {
let dir = drawcraft_testkit::temp_dir("mcp-junk");
let safe = dir.join("junk.drawcraft").to_string_lossy().to_string();
let mut failures = vec![];
for t in tool_definitions() {
let name = t["name"].as_str().unwrap().to_string();
let props: Vec<String> = t["inputSchema"]["properties"].as_object().unwrap().keys().cloned().collect();
let mut cases = vec![json!({}), Value::Null, json!([]), json!("x"), json!(3)];
for k in &props {
for v in junk_values() {
// Never let fuzzed strings name files; keep renders small.
let v = if k == "path" {
json!(safe)
} else if k == "scale" && v.as_f64().is_some_and(|x| x.abs() > 4.0) {
json!(4)
} else {
v
};
cases.push(json!({ k.clone(): v }));
}
}
for args in cases {
let mut s = server();
let r = catch_quiet(|| {
let v = call(&mut s, &name, args.clone());
if let Some(res) = v.get("result") {
assert!(res["content"].is_array(), "{name}: result has no content array: {v}");
}
check_document(&document(&mut s))
});
match r {
Err(p) => failures.push(format!("PANIC {name} {args}: {p}")),
Ok(Err(e)) => failures.push(format!("{name} {args}: {e}")),
Ok(Ok(())) => {}
}
}
}
assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n"));
}
#[test]
fn framing_never_panics_on_malformed_messages() {
let mut s = server();
for line in [
"",
"null",
"[]",
"[1,2,3]",
"{}",
"{\"jsonrpc\":\"2.0\",\"id\":{},\"method\":\"ping\"}",
"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":5}",
"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\"}",
"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"nope\"}}",
"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"run_command\",\"arguments\":[]}}",
"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"resources/read\",\"params\":{\"uri\":\"nope://x\"}}",
"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"1900-01-01\"}}",
"\u{0}\u{1}",
"{\"jsonrpc\":\"2.0\",\"id\":1e400,\"method\":\"ping\"}",
] {
let r = catch_quiet(|| s.handle_line(line));
assert!(r.is_ok(), "panic on {line:?}");
if let Ok(Some(reply)) = r {
let v: Value = serde_json::from_str(&reply).unwrap_or_else(|e| panic!("{line:?} → invalid JSON reply {reply}: {e}"));
assert!(v.is_object() || v.is_array());
}
}
// Still alive afterwards.
rpc(&mut s, 7, "ping", json!({}));
}
#[test]
fn resources_are_json() {
let mut s = server();
let uri = doc_uri_json(&mut s);
let v = rpc(&mut s, 1, "resources/read", json!({"uri": uri}));
let d: Document = serde_json::from_str(v["result"]["contents"][0]["text"].as_str().unwrap()).unwrap();
check_document(&d).unwrap();
let list = rpc(&mut s, 2, "resources/list", json!({}));
for r in list["result"]["resources"].as_array().unwrap() {
let v = rpc(&mut s, 3, "resources/read", json!({"uri": r["uri"]}));
let text = v["result"]["contents"][0]["text"].as_str().unwrap();
serde_json::from_str::<Value>(text).unwrap();
}
}
#[test]
fn undo_redo_tools_restore_documents() {
let mut s = server();
let d0 = doc_json(&document(&mut s));
let v = call(&mut s, "draw_shape", json!({"shape": "rectangle", "x": 10, "y": 10, "width": 50, "height": 40, "fill": "#ff0000"}));
assert!(v["result"]["isError"] != json!(true), "{v}");
call(&mut s, "draw_shape", json!({"shape": "star", "cx": 100, "cy": 100, "radius1": 40, "radius2": 20}));
let d2 = doc_json(&document(&mut s));
// draw + paint are separate undo steps; undo until the original document is back.
let mut n = 0;
while doc_json(&document(&mut s)) != d0 {
let v = call(&mut s, "undo", json!({}));
assert!(v["result"]["isError"] != json!(true), "undo failed early: {v}");
n += 1;
assert!(n < 20);
}
for _ in 0..n {
call(&mut s, "redo", json!({}));
}
assert_eq!(doc_json(&document(&mut s)), d2);
}
proptest! {
#![proptest_config(ProptestConfig { cases: 24, failure_persistence: None, ..ProptestConfig::default() })]
/// Random engine command sequences through `run_command` keep the document valid and the
/// server responsive; the result equals running the same commands on a local session.
#[test]
fn run_command_sequences_match_local_session(ops in arb_ops(5..40)) {
let mut srv = server();
let mut local = drawcraft_testkit::fixtures::session_with(612.0, 792.0);
// Match the headless default document.
let d_remote = doc_json(&document(&mut srv));
let d_local = doc_json(&local.doc().unwrap().doc);
prop_assume!(d_remote == d_local);
for op in &ops {
let (id, params) = op.command(&local);
let a = local.execute(&id, &params).is_ok();
let v = call(&mut srv, "run_command", json!({"command": id, "params": params}));
let b = v["result"]["isError"] != json!(true);
prop_assert_eq!(a, b, "`{}` {}: local ok={} mcp ok={} ({})", id, params, a, b, v);
}
let got = document(&mut srv);
check_document(&got).map_err(TestCaseError::fail)?;
{ let (g, l) = (doc_json(&got), doc_json(&local.doc().unwrap().doc)); // 1e-12: JSON text round trips aren't bit-exact (serde_json without float_roundtrip; see
// crates/format/tests/prop_format.rs::bug_f64_bit_exact_roundtrip).
prop_assert!(drawcraft_testkit::invariants::json_approx_eq(&g, &l, 1e-12), "MCP and local sessions diverged: {}", drawcraft_testkit::invariants::first_diff(&l, &g, "$")); }
}
}
/// Tools driven directly against a Headless backend never leave an interaction open or corrupt
/// the session, whatever the arguments. (A gesture that stops after `drag` legitimately stays open
/// so an agent can continue it in the next call; that case is not listed.)
#[test]
fn tools_never_leave_interactions_open() {
use drawcraft_mcp::call_tool;
use drawcraft_testkit::invariants::check_session;
let cases = [
("pointer_gesture", json!({"tool": "rectangle", "events": [{"kind": "down", "x": 10, "y": 10}, {"kind": "bogus", "x": 50, "y": 50}]})),
("pointer_gesture", json!({"tool": "pen", "events": [{"kind": "down", "x": 10, "y": 10}, {"kind": "drag", "x": "a", "y": 50}]})),
(
"pointer_gesture",
json!({"tool": "selection", "events": [{"kind": "down", "x": 1e308, "y": -1e308}, {"kind": "drag", "x": 1e308, "y": 0}, {"kind": "up", "x": 0, "y": 0}]}),
),
("pointer_gesture", json!({"tool": "ellipse", "events": [{"kind": "up", "x": 0, "y": 0}]})),
("press_key", json!({"key": "Escape"})),
(
"run_command",
json!({"command": "command.batch", "params": {"commands": [{"command": "shape.rectangle", "params": {"x": 0, "y": 0, "width": 5, "height": 5}}, {"command": "nope"}]}}),
),
];
let mut failures = vec![];
for (name, args) in cases {
let mut h = Headless::with_document();
let r = catch_quiet(|| call_tool(&mut h, name, &args));
if r.is_err() {
failures.push(format!("PANIC {name} {args}"));
continue;
}
// A finished gesture sequence must not leave a drag half-applied.
if h.session.in_interaction() {
failures.push(format!("{name} {args}: interaction left open"));
}
if let Err(e) = check_session(&h.session) {
failures.push(format!("{name} {args}: {e}"));
}
}
assert!(failures.is_empty(), "{}", failures.join("\n"));
}