mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-28 04:06:54 -06:00
Callers from outside the app now get a parameter error for a key a
command's params description doesn't name, instead of the command
running with its defaults: `text.select {story, start, end}` and
`style.list {bogus: 1}` are refused with "unknown parameter `…`" and the
nearest or the accepted keys.
- engine: `cmd::params` (ported from VectorCraft #1442) reads the keys
from each params description; `check_params` / `check_keys`.
`Session::execute` itself stays lenient so old journals replay; in the
engine's own tests it checks too, as the audit of the descriptions.
- MCP: tools/call answers -32602 for an argument a tool doesn't take and
for params a command doesn't take (`execute`, every `batch` step before
the first runs); the headless backend checks as well.
- Control channel (`engine.execute`) and `designcraft-cli run --cmd`.
- Descriptions now name every key their commands read (table target
cells, anchored objects, gap.move, file.place, data merge, prefs.set,
…); type.char / type.para stay unchecked (their attributes may be the
params object itself; the setters refuse unknown attributes).
- Tests that passed keys the commands never read are fixed, and the pen
tool no longer sends the unread `notifyPen`.
Fixes #776
212 lines
10 KiB
Rust
212 lines
10 KiB
Rust
//! JSON-RPC 2.0 framing and the MCP lifecycle / tools / resources methods.
|
||
|
||
use std::io::{BufRead, Write};
|
||
|
||
use serde_json::{Value, json};
|
||
|
||
use crate::backend::Backend;
|
||
use crate::tools::{call_tool, tool_definitions};
|
||
|
||
/// The MCP revision we implement.
|
||
pub const PROTOCOL_VERSION: &str = "2025-06-18";
|
||
const SUPPORTED_VERSIONS: &[&str] = &[PROTOCOL_VERSION, "2025-03-26", "2024-11-05"];
|
||
|
||
const PARSE_ERROR: i64 = -32700;
|
||
const INVALID_REQUEST: i64 = -32600;
|
||
const METHOD_NOT_FOUND: i64 = -32601;
|
||
const INVALID_PARAMS: i64 = -32602;
|
||
const INTERNAL_ERROR: i64 = -32603;
|
||
const RESOURCE_NOT_FOUND: i64 = -32002;
|
||
|
||
const INSTRUCTIONS: &str = "DesignCraft is a page-layout app (an InDesign clone). Coordinates are points in spread \
|
||
space (y down; on a single-page spread the page's top-left is (0,0); a default Letter page is 612×792). Every action \
|
||
is a command: find ids and parameters with list_commands and run them with execute (or several with batch). Typical \
|
||
flow: new_document → execute frame.create {rect:[x0,y0,x1,y1], content:\"text\", text:\"…\"} → set_story_text / \
|
||
execute type.char / style.paragraph.apply → render_page to look at the result. inspect_document lists pages, items \
|
||
(ids, bounds), stories (overset) and styles. place_image puts a picture on the page.";
|
||
|
||
/// Resource URIs.
|
||
pub const DOC_URI: &str = "designcraft://document";
|
||
pub const COMMANDS_URI: &str = "designcraft://commands";
|
||
|
||
/// An MCP server bound to one backend.
|
||
pub struct Server {
|
||
backend: Box<dyn Backend>,
|
||
initialized: bool,
|
||
}
|
||
|
||
fn response(id: Value, result: Value) -> Value {
|
||
json!({"jsonrpc": "2.0", "id": id, "result": result})
|
||
}
|
||
|
||
fn error(id: Value, code: i64, message: impl Into<String>) -> Value {
|
||
json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message.into()}})
|
||
}
|
||
|
||
impl Server {
|
||
pub fn new(backend: Box<dyn Backend>) -> Self {
|
||
Self { backend, initialized: false }
|
||
}
|
||
|
||
pub fn backend(&mut self) -> &mut dyn Backend {
|
||
self.backend.as_mut()
|
||
}
|
||
|
||
/// Whether the client has sent `notifications/initialized`.
|
||
pub fn is_initialized(&self) -> bool {
|
||
self.initialized
|
||
}
|
||
|
||
/// 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())?;
|
||
output.write_all(b"\n")?;
|
||
output.flush()?;
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Handle one line; returns the reply line (None for notifications and blank lines).
|
||
pub fn handle_line(&mut self, line: &str) -> Option<String> {
|
||
let line = line.trim();
|
||
if line.is_empty() {
|
||
return None;
|
||
}
|
||
let reply = match serde_json::from_str::<Value>(line) {
|
||
Ok(Value::Array(batch)) => {
|
||
// Batches were removed in 2025-06-18; still answer older clients sensibly.
|
||
if batch.is_empty() {
|
||
Some(error(Value::Null, INVALID_REQUEST, "empty batch"))
|
||
} else {
|
||
let replies: Vec<Value> = batch.into_iter().filter_map(|m| self.handle(m)).collect();
|
||
(!replies.is_empty()).then_some(Value::Array(replies))
|
||
}
|
||
}
|
||
Ok(msg) => self.handle(msg),
|
||
Err(e) => Some(error(Value::Null, PARSE_ERROR, format!("parse error: {e}"))),
|
||
};
|
||
reply.map(|r| r.to_string())
|
||
}
|
||
|
||
/// Handle one JSON-RPC message; `None` for notifications and responses.
|
||
pub fn handle(&mut self, msg: Value) -> Option<Value> {
|
||
let Value::Object(o) = &msg else { return Some(error(Value::Null, INVALID_REQUEST, "message must be an object")) };
|
||
let id = o.get("id").cloned();
|
||
let Some(method) = o.get("method").and_then(Value::as_str) else {
|
||
// A response to a server→client request (we send none): ignore. Anything else is invalid.
|
||
if o.contains_key("result") || o.contains_key("error") {
|
||
return None;
|
||
}
|
||
return Some(error(id.unwrap_or(Value::Null), INVALID_REQUEST, "missing `method`"));
|
||
};
|
||
let params = o.get("params").cloned().unwrap_or(Value::Null);
|
||
let Some(id) = id else {
|
||
self.notification(method);
|
||
return None;
|
||
};
|
||
if !(id.is_string() || id.is_number()) {
|
||
return Some(error(Value::Null, INVALID_REQUEST, "`id` must be a string or number"));
|
||
}
|
||
Some(match self.request(method, ¶ms) {
|
||
Ok(r) => response(id, r),
|
||
Err((code, m)) => error(id, code, m),
|
||
})
|
||
}
|
||
|
||
fn notification(&mut self, method: &str) {
|
||
match method {
|
||
"notifications/initialized" => self.initialized = true,
|
||
"notifications/cancelled" | "notifications/progress" | "notifications/roots/list_changed" => {}
|
||
other => log::debug!("ignoring notification {other}"),
|
||
}
|
||
}
|
||
|
||
fn request(&mut self, method: &str, params: &Value) -> Result<Value, (i64, String)> {
|
||
match method {
|
||
"initialize" => {
|
||
let asked = params.get("protocolVersion").and_then(Value::as_str).unwrap_or(PROTOCOL_VERSION);
|
||
let version = if SUPPORTED_VERSIONS.contains(&asked) { asked } else { PROTOCOL_VERSION };
|
||
Ok(json!({
|
||
"protocolVersion": version,
|
||
"capabilities": {"tools": {}, "resources": {}},
|
||
"serverInfo": {"name": "designcraft", "title": "DesignCraft", "version": env!("CARGO_PKG_VERSION"), "websiteUrl": designcraft_engine::links::APP_PAGE},
|
||
"instructions": format!(
|
||
"{INSTRUCTIONS} Backend: {}. Community: {} (Discord), {} (app page), {} (source). The `app.links` command returns these links.",
|
||
self.backend.describe(),
|
||
designcraft_engine::links::DISCORD,
|
||
designcraft_engine::links::APP_PAGE,
|
||
designcraft_engine::links::GITHUB
|
||
),
|
||
}))
|
||
}
|
||
"ping" => Ok(json!({})),
|
||
"tools/list" => Ok(json!({"tools": tool_definitions()})),
|
||
"tools/call" => {
|
||
let name = params.get("name").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing tool `name`".to_string()))?;
|
||
let args = params.get("arguments").cloned().unwrap_or(Value::Null);
|
||
check_arguments(name, &args).map_err(|m| (INVALID_PARAMS, m))?;
|
||
Ok(call_tool(self.backend.as_mut(), name, &args).to_value())
|
||
}
|
||
"resources/list" => Ok(json!({"resources": [
|
||
{"uri": DOC_URI, "name": "document", "title": "Active document", "description": "Pages, spreads, items, stories, styles, swatches and selection of the active document (document.inspect)", "mimeType": "application/json"},
|
||
{"uri": COMMANDS_URI, "name": "commands", "title": "Commands", "description": "Every command: id, label, menu path, shortcut, params, enabled (engine.commands)", "mimeType": "application/json"},
|
||
]})),
|
||
"resources/templates/list" => Ok(json!({"resourceTemplates": []})),
|
||
"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!({})),
|
||
COMMANDS_URI => self.backend.call("engine.commands", json!({})),
|
||
_ => return Err((RESOURCE_NOT_FOUND, format!("resource not found: {uri}"))),
|
||
}
|
||
.map_err(|e| (INTERNAL_ERROR, e))?;
|
||
Ok(json!({"contents": [{"uri": uri, "mimeType": "application/json", "text": serde_json::to_string_pretty(&v).unwrap_or_default()}]}))
|
||
}
|
||
other => Err((METHOD_NOT_FOUND, format!("method not found: {other}"))),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Arguments a tool doesn't take, and command params a command doesn't take, are invalid params
|
||
/// (#776): a misspelt key is refused instead of being ignored. `execute` and `batch` are checked
|
||
/// against the params descriptions of the commands they run, every step before the first runs.
|
||
fn check_arguments(name: &str, args: &Value) -> Result<(), String> {
|
||
let Some(arguments) = args.as_object() else { return Ok(()) };
|
||
if let Some(def) = tool_definitions().into_iter().find(|t| t.get("name").and_then(Value::as_str) == Some(name))
|
||
&& let Some(properties) = def.get("inputSchema").and_then(|s| s.get("properties")).and_then(Value::as_object)
|
||
&& let Some(key) = arguments.keys().find(|k| !properties.contains_key(*k))
|
||
{
|
||
return Err(format!("unknown argument `{key}` for `{name}`"));
|
||
}
|
||
let command = |step: &Value| -> Result<(), String> {
|
||
match (step.get("command").and_then(Value::as_str), step.get("params")) {
|
||
(Some(id), Some(params)) => designcraft_engine::check_params(id, params).map_err(|e| e.to_string()),
|
||
_ => Ok(()),
|
||
}
|
||
};
|
||
match name {
|
||
"execute" => command(args),
|
||
"batch" => {
|
||
let steps = match (arguments.get("commands").and_then(Value::as_array), arguments.get("script").and_then(Value::as_str)) {
|
||
(Some(steps), _) => steps.clone(),
|
||
// A script that doesn't parse is the tool's to report.
|
||
(None, Some(text)) => match designcraft_engine::script::parse(text) {
|
||
Ok(steps) => steps.into_iter().map(|s| json!({"command": s.command, "params": s.params})).collect(),
|
||
Err(_) => return Ok(()),
|
||
},
|
||
(None, None) => return Ok(()),
|
||
};
|
||
for (i, step) in steps.iter().enumerate() {
|
||
command(step).map_err(|e| format!("step {i}: {e}"))?;
|
||
}
|
||
Ok(())
|
||
}
|
||
_ => Ok(()),
|
||
}
|
||
}
|