Files
deckcraft/crates/mcp/src/tests.rs
T
5c13f6ec72 MCP: the export tool lists PDF and its layout options (#65)
tools/list described export as png/jpeg/pptx/outline only, with no PDF parameters, although
file.export (which the tool runs) writes PDF slides, notes pages and handouts. The tool's
description now names PDF, format is an enum of the formats file.export writes, and the schema
lists the PDF options: layout, perPage, dpi, slides, includeHidden, textLayer and frame.

Co-authored-by: lloyd <lloyd@stackworx.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 06:17:37 -04:00

120 lines
6.0 KiB
Rust

//! An agent builds a small deck entirely through MCP and checks its work only through MCP.
use serde_json::{Value, json};
use crate::{Headless, Server};
fn call(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 out = s.handle_line(&line).expect("a response");
serde_json::from_str(&out).expect("json")
}
fn tool(s: &mut Server, name: &str, args: Value) -> Value {
let r = call(s, 9, "tools/call", json!({"name": name, "arguments": args}));
let res = &r["result"];
assert_eq!(res["isError"], false, "{name} failed: {res}");
let text =
res["content"].as_array().unwrap().iter().find(|c| c["type"] == "text").map(|c| c["text"].as_str().unwrap().to_string()).unwrap_or_default();
serde_json::from_str(&text).unwrap_or(Value::String(text))
}
#[test]
fn handshake_and_tool_list() {
let mut s = Server::new(Box::new(Headless::new()));
let r = call(&mut s, 1, "initialize", json!({"protocolVersion": "2025-06-18"}));
assert_eq!(r["result"]["serverInfo"]["name"], "deckcraft");
let tools = call(&mut s, 2, "tools/list", json!({}));
let names: Vec<&str> = tools["result"]["tools"].as_array().unwrap().iter().map(|t| t["name"].as_str().unwrap()).collect();
for n in ["run_command", "render_slide", "add_slide", "add_shape", "pointer", "inspect_slide"] {
assert!(names.contains(&n), "{n}");
}
// Garbage never panics.
assert!(s.handle_line("not json").is_some());
assert!(s.handle_line("{}").is_some());
}
#[test]
fn agent_builds_a_deck() {
let mut s = Server::new(Box::new(Headless::new()));
tool(&mut s, "new_presentation", json!({"theme": "Harbor"}));
let doc = tool(&mut s, "inspect_document", json!({}));
let title_id = {
let sl = tool(&mut s, "inspect_slide", json!({}));
sl["shapes"][0]["id"].as_u64().unwrap()
};
let _ = doc;
tool(&mut s, "set_text", json!({"id": title_id, "text": "Quarterly Review"}));
tool(&mut s, "add_slide", json!({"layout": "titleAndContent", "title": "Highlights", "body": "Revenue up\n\tNew markets\nCosts down"}));
let shape = tool(&mut s, "add_shape", json!({"preset": "roundRect", "x": 600, "y": 300, "w": 200, "h": 100, "text": "Goal", "fill": "accent2"}));
let sid = shape["id"].as_u64().unwrap();
tool(&mut s, "run_command", json!({"command": "shape.rotate", "params": {"id": sid, "deg": 10}}));
// Drag the shape with the pointer.
tool(
&mut s,
"pointer",
json!({"events": [{"kind": "down", "x": 700, "y": 350}, {"kind": "drag", "x": 650, "y": 330}, {"kind": "up", "x": 650, "y": 330}]}),
);
tool(&mut s, "insert_chart", json!({"type": "pie", "categories": ["A", "B"], "series": [{"name": "S", "values": [3, 5]}]}));
let sl = tool(&mut s, "inspect_slide", json!({}));
assert_eq!(sl["title"], "Highlights");
let shapes = sl["shapes"].as_array().unwrap();
let goal = shapes.iter().find(|x| x["id"].as_u64() == Some(sid)).unwrap();
assert_eq!(goal["text"], "Goal");
assert_eq!(goal["rotation"], 10.0);
assert!(goal["box"][0].as_f64().unwrap() < 600.0, "moved left: {goal}");
assert!(shapes.iter().any(|x| x["kind"] == "chart"));
let doc = tool(&mut s, "inspect_document", json!({}));
assert_eq!(doc["slides"].as_array().unwrap().len(), 2);
assert_eq!(doc["theme"], "Harbor");
// Look at it.
let r = call(&mut s, 10, "tools/call", json!({"name": "render_slide", "arguments": {"scale": 0.25}}));
assert_eq!(r["result"]["content"][0]["type"], "image");
// Keyboard: select the shape, delete it, undo.
tool(&mut s, "select", json!({"ids": [sid]}));
tool(&mut s, "key", json!({"key": "Delete"}));
let n1 = tool(&mut s, "inspect_slide", json!({}))["shapes"].as_array().unwrap().len();
tool(&mut s, "key", json!({"key": "z", "mods": {"cmd": true}}));
let n2 = tool(&mut s, "inspect_slide", json!({}))["shapes"].as_array().unwrap().len();
assert_eq!(n2, n1 + 1);
}
#[test]
fn errors_are_reported_not_panics() {
let mut s = Server::new(Box::new(Headless::new()));
let r = call(&mut s, 1, "tools/call", json!({"name": "run_command", "arguments": {"command": "no.such"}}));
assert_eq!(r["result"]["isError"], true);
let r = call(&mut s, 2, "tools/call", json!({"name": "screenshot", "arguments": {}}));
assert_eq!(r["result"]["isError"], true);
let r = call(&mut s, 3, "tools/call", json!({"name": "add_shape", "arguments": {"preset": "rect"}}));
assert_eq!(r["result"]["isError"], true);
}
#[test]
fn export_tool_lists_pdf_and_its_options() {
let mut s = Server::new(Box::new(Headless::new()));
let tools = call(&mut s, 1, "tools/list", json!({}));
let export = tools["result"]["tools"].as_array().unwrap().iter().find(|t| t["name"] == "export").unwrap().clone();
assert!(export["description"].as_str().unwrap().contains("pdf"), "{export}");
let props = &export["inputSchema"]["properties"];
let formats = props["format"]["enum"].as_array().unwrap();
for f in ["png", "jpeg", "pptx", "deckcraft", "outline", "pdf"] {
assert!(formats.iter().any(|x| x == f), "format {f}: {export}");
}
assert_eq!(props["layout"]["enum"], json!(["slides", "notes", "handouts"]));
assert_eq!(props["perPage"]["enum"], json!([1, 2, 3, 4, 6, 9]));
for p in ["dpi", "slides", "includeHidden", "textLayer", "frame"] {
assert!(props.get(p).is_some(), "{p}: {export}");
}
// What the schema describes is accepted.
let dir = std::env::temp_dir().join(format!("deckcraft-mcp-export-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
tool(&mut s, "new_presentation", json!({}));
for layout in ["slides", "notes", "handouts"] {
let path = dir.join(format!("{layout}.pdf"));
tool(&mut s, "export", json!({"path": path.to_string_lossy(), "format": "pdf", "layout": layout, "perPage": 3}));
assert!(std::fs::read(&path).unwrap().starts_with(b"%PDF"), "{layout}");
}
let _ = std::fs::remove_dir_all(&dir);
}