diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index 99b7ac616..6d7b1b862 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -23,14 +23,25 @@ const INVALID_PARAMS: i64 = -32602; const INTERNAL_ERROR: i64 = -32603; const RESOURCE_NOT_FOUND: i64 = -32002; +/// The first revision with `structuredContent`, `outputSchema` and `title`. +const STRUCTURED_SINCE: &str = "2025-06-18"; + pub struct McpServer { backend: Backend, autosave: Option, + /// The protocol revision `initialize` negotiated (`None` before it). + version: Option<&'static str>, } impl McpServer { pub fn new(backend: Backend) -> Self { - Self { backend, autosave: None } + Self { backend, autosave: None, version: None } + } + + /// Whether replies to this request may use 2025-06-18 fields (`structuredContent`, + /// `outputSchema`, `title`): the session negotiated it, or the request declares a modern revision. + fn structured(&self, params: &Value) -> bool { + is_modern(params) || self.version.is_some_and(|v| v >= STRUCTURED_SINCE) } /// Opt in to durable headless checkpoints in a separate folder per server. @@ -161,7 +172,9 @@ impl McpServer { match method { "initialize" => { let asked = params.get("protocolVersion").and_then(Value::as_str).unwrap_or(""); - let version = PROTOCOL_VERSIONS.iter().find(|v| **v == asked).copied().unwrap_or(PROTOCOL_VERSIONS.first().copied().unwrap_or("2025-06-18")); + let version = + PROTOCOL_VERSIONS.iter().find(|v| **v == asked).copied().unwrap_or(PROTOCOL_VERSIONS.first().copied().unwrap_or(STRUCTURED_SINCE)); + self.version = Some(version); let mode = if self.backend.is_bridge() { "bridge" } else { "headless" }; let instructions = if self.autosave.is_some() { format!("{INSTRUCTIONS} Headless auto-save and recovery: {}", self.autosave_info()) @@ -178,7 +191,18 @@ impl McpServer { } "ping" => Ok(json!({})), "tools/list" => { - let tools: Vec = tools::available(self.backend.is_bridge()).map(|t| t.descriptor()).collect(); + let structured = self.structured(params); + let tools: Vec = tools::available(self.backend.is_bridge()) + .map(|t| { + let mut d = t.descriptor(); + if let Some(schema) = tools::output_schema(t.name).filter(|_| structured) + && let Some(o) = d.as_object_mut() + { + o.insert("outputSchema".into(), schema); + } + d + }) + .collect(); Ok(json!({"tools": tools})) } "tools/call" => { @@ -191,11 +215,26 @@ impl McpServer { if let Some(m) = tools::find(name).and_then(|t| t.unknown_arg(&args)) { return Err((INVALID_PARAMS, m)); } - let mut result = call_result(self.guarded(name, &args)); + let mut reply = self.guarded(name, &args); // Also checkpoint a partially successful tool that returned an error. The // project is durable before the client receives its reply (even if it kills // the process immediately afterwards). - if let Err(e) = self.checkpoint(false) { + let saved = self.checkpoint(false); + if self.autosave.is_some() + && let Ok(Reply::Json(v)) = &mut reply + { + let info = self.autosave_info(); + let target = match name { + "get_project" => v.as_object_mut(), + "doc_inspect" => v.get_mut("project").and_then(Value::as_object_mut), + _ => None, + }; + if let Some(o) = target { + o.insert("autosave".into(), info); + } + } + let mut result = call_result_with(reply, self.structured(params)); + if let Err(e) = saved { let _ = writeln!(std::io::stderr(), "{e}"); if let Some(content) = result.get_mut("content").and_then(Value::as_array_mut) { content.push(json!({"type": "text", "text": format!("Warning: {e}. Use save_project with a writable path before disconnecting.")})); @@ -209,16 +248,6 @@ impl McpServer { meta.insert("effectcraftAutoSave".into(), self.autosave_info()); } } - if self.autosave.is_some() - && name == "get_project" - && result["isError"] == false - && let Some(text) = result.get_mut("content").and_then(Value::as_array_mut).and_then(|c| c.first_mut()).and_then(|c| c.get_mut("text")) - && let Some(summary) = text.as_str().and_then(|t| serde_json::from_str::(t).ok()) - { - let mut summary = summary; - summary["autosave"] = self.autosave_info(); - *text = Value::String(summary.to_string()); - } Ok(result) } "resources/list" => Ok(json!({"resources": [ @@ -247,19 +276,35 @@ impl McpServer { /// A `tools/call` result: tool failures are reported in-band (`isError`) so the model can react. pub fn call_result(r: Result) -> Value { - match r { + call_result_with(r, false) +} + +/// [`call_result`]; `structured` (MCP 2025-06-18+) also returns the JSON as `structuredContent` +/// (an image's metadata for image replies), keeping the text content for older clients. +pub fn call_result_with(r: Result, structured: bool) -> Value { + let (mut result, data) = match r { Ok(Reply::Json(v)) => { - json!({"content": [{"type": "text", "text": v.to_string()}], "isError": v.get("failed").and_then(Value::as_u64).is_some_and(|n| n > 0)}) + let failed = v.get("failed").and_then(Value::as_u64).is_some_and(|n| n > 0); + (json!({"content": [{"type": "text", "text": v.to_string()}], "isError": failed}), Some(v)) } - Ok(Reply::Image { png, info }) => json!({ - "content": [ - {"type": "image", "data": base64::encode(&png), "mimeType": "image/png"}, - {"type": "text", "text": info.to_string()}, - ], - "isError": false, - }), - Err(e) => json!({"content": [{"type": "text", "text": e.to_string()}], "isError": true}), + Ok(Reply::Image { png, info }) => ( + json!({ + "content": [ + {"type": "image", "data": base64::encode(&png), "mimeType": "image/png"}, + {"type": "text", "text": info.to_string()}, + ], + "isError": false, + }), + Some(info), + ), + Err(e) => (json!({"content": [{"type": "text", "text": e.to_string()}], "isError": true}), None), + }; + if let Some(v) = data.filter(|_| structured) + && let Some(o) = result.as_object_mut() + { + o.insert("structuredContent".into(), tools::structured(v)); } + result } impl McpServer { diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index 37de10a50..e8af9e700 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -1252,3 +1252,107 @@ fn automation_roots_confine_file_access() { assert_eq!(std::fs::read_dir(&outside).unwrap().count(), 1, "only the unconfined save"); let _ = std::fs::remove_dir_all(&base); } + +/// A minimal JSON Schema check (type, required, properties, items): enough for outputSchema. +fn validate(schema: &Value, v: &Value, at: &str) -> Result<(), String> { + if let Some(t) = schema.get("type") { + let types: Vec<&str> = match t { + Value::String(s) => vec![s.as_str()], + Value::Array(a) => a.iter().filter_map(Value::as_str).collect(), + _ => vec![], + }; + let ok = types.iter().any(|t| match *t { + "object" => v.is_object(), + "array" => v.is_array(), + "string" => v.is_string(), + "integer" => v.is_i64() || v.is_u64(), + "number" => v.is_number(), + "boolean" => v.is_boolean(), + "null" => v.is_null(), + _ => false, + }); + if !ok { + return Err(format!("{at}: {v} is not {t}")); + } + } + for r in schema.get("required").and_then(Value::as_array).into_iter().flatten() { + if v.get(r.as_str().unwrap()).is_none() { + return Err(format!("{at}: missing `{r}` in {v}")); + } + } + if let (Some(props), Some(o)) = (schema.get("properties").and_then(Value::as_object), v.as_object()) { + for (k, s) in props { + if let Some(x) = o.get(k) { + validate(s, x, &format!("{at}.{k}"))?; + } + } + } + if let (Some(items), Some(a)) = (schema.get("items"), v.as_array()) { + for (i, x) in a.iter().enumerate() { + validate(items, x, &format!("{at}[{i}]"))?; + } + } + Ok(()) +} + +/// MCP 2025-06-18: JSON results also come as `structuredContent` (always an object) and the +/// stable ones declare an `outputSchema` it validates against; older revisions get neither, and +/// keep the text content either way. +#[test] +fn structured_content_is_version_gated_and_matches_output_schemas() { + for (version, structured) in [("2025-06-18", true), ("2025-03-26", false), ("2024-11-05", false)] { + let mut s = server(); + rpc(&mut s, 1, "initialize", json!({"protocolVersion": version})); + let tools = rpc(&mut s, 2, "tools/list", json!({}))["tools"].as_array().unwrap().clone(); + let declared = tools.iter().filter(|t| t.get("outputSchema").is_some()).count(); + assert_eq!(declared > 0, structured, "{version}"); + let r = rpc(&mut s, 3, "tools/call", json!({"name": "command_run", "arguments": {"id": "comp.new", "params": {"width": 32, "height": 32}}})); + assert_eq!(r.get("structuredContent").is_some(), structured, "{version}: {r}"); + assert_eq!(r["content"][0]["type"], "text", "{version}: text content stays"); + } + // A modern request (revision in `_meta`, no initialize) gets them too. + let mut s = server(); + let meta = json!({"io.modelcontextprotocol/protocolVersion": "2026-07-28"}); + let r = rpc(&mut s, 1, "tools/call", json!({"_meta": meta, "name": "get_state", "arguments": {}})); + assert!(r["structuredContent"]["state"].is_object(), "{r}"); + + let mut s = server(); + rpc(&mut s, 1, "initialize", json!({"protocolVersion": "2025-06-18"})); + let tools = rpc(&mut s, 2, "tools/list", json!({}))["tools"].as_array().unwrap().clone(); + let schema = |n: &str| tools.iter().find(|t| t["name"] == n).and_then(|t| t.get("outputSchema")).cloned().unwrap_or_else(|| panic!("{n}: no outputSchema")); + for t in &tools { + if let Some(o) = t.get("outputSchema") { + assert_eq!(o["type"], "object", "{t}"); + } + } + let path = std::env::temp_dir().join(format!("ec-structured-{}.ecproj", std::process::id())).to_string_lossy().into_owned(); + let png = std::env::temp_dir().join(format!("ec-structured-{}.png", std::process::id())).to_string_lossy().into_owned(); + let calls = [ + ("command_run", json!({"id": "comp.new", "params": {"name": "S", "width": 64, "height": 36}})), + ("command_batch", json!({"steps": [{"id": "layer.newNull"}, {"id": "no.such"}], "stop_on_error": false})), + ("batch", json!({"steps": [{"command": "layer.newSolid", "params": {"color": "#ff0000"}}]})), + ("get_state", json!({})), + ("doc_inspect", json!({})), + ("render_preview", json!({"max_side": 16})), + ("render_frame", json!({"max_side": 16, "inline": false, "path": png})), + ("undo", json!({})), + ("redo", json!({})), + ("history", json!({})), + ("save_project", json!({"path": path})), + ("command_list", json!({"filter": "comp.new"})), + ]; + for (name, args) in calls { + let r = rpc(&mut s, 3, "tools/call", json!({"name": name, "arguments": args})); + let sc = &r["structuredContent"]; + assert!(sc.is_object(), "{name}: {r}"); + if r["content"][0]["type"] == "text" { + let text: Value = serde_json::from_str(r["content"][0]["text"].as_str().unwrap()).unwrap(); + assert!(sc == &text || sc["items"] == text, "{name}: structuredContent mirrors the text"); + } + if name != "command_list" && name != "command_run" { + validate(&schema(name), sc, name).unwrap_or_else(|e| panic!("{name}: {e}")); + } + } + let _ = std::fs::remove_file(path); + let _ = std::fs::remove_file(png); +} diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index fa86095be..3723afe49 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -107,6 +107,51 @@ pub fn available(bridge: bool) -> impl Iterator { /// shortcuts, Open Recent…) or call control-channel methods directly, past the command checks. const UNCONFINED_ONLY: &[&str] = &["control", "ui_click", "ui_drag", "ui_key", "ui_type", "ui_set", "script_ui"]; +/// The JSON Schema of a tool's `structuredContent` (MCP 2025-06-18 `outputSchema`), for tools +/// whose result shape is stable. Clients that negotiated 2025-06-18+ get every JSON result as +/// `structuredContent` too ([`structured`]); only these declare its shape. +pub fn output_schema(name: &str) -> Option { + let object = |props: Value, required: &[&str]| json!({"type": "object", "properties": props, "required": required}); + let int = json!({"type": "integer"}); + Some(match name { + "get_state" => object(json!({"state": {"type": "object"}, "app": {"type": "object"}}), &["state", "app"]), + "doc_inspect" => object(json!({"project": {"type": "object"}, "activeComp": {"type": ["object", "null"]}}), &["project", "activeComp"]), + "render_preview" | "render_frame" => object( + json!({ + "comp": {"type": ["integer", "string", "null"], "description": "Composition id."}, + "time": {"type": "number", "description": "Comp time in seconds."}, + "width": int, "height": int, + "path": {"type": "string", "description": "Where the PNG was written (with `path`)."} + }), + &["comp", "time", "width", "height"], + ), + "undo" | "redo" => object(json!({"steps": int, "undoStack": {"type": "array", "items": {"type": "string"}}}), &["steps", "undoStack"]), + "history" => { + object(json!({"states": {"type": "array", "items": {"type": "object"}}, "current": {"type": ["integer", "null"]}, "branches": int}), &["states"]) + } + "command_batch" => object( + json!({ + "completed": int, "failed": int, + "results": {"type": "array", "items": object(json!({"ok": {"type": "boolean"}, "result": {}, "error": {"type": "string"}}), &["ok"])} + }), + &["completed", "failed", "results"], + ), + "batch" => object(json!({"steps": int, "results": {"type": "array"}}), &["steps", "results"]), + "save_project" => object(json!({"path": {"type": ["string", "null"]}, "dirty": {"type": "boolean"}}), &["path", "dirty"]), + _ => return None, + }) +} + +/// A JSON result as MCP `structuredContent`, which must be an object: arrays become +/// `{"items": [...]}`, other values `{"value": v}`. +pub fn structured(v: Value) -> Value { + match v { + Value::Object(_) => v, + Value::Array(_) => json!({"items": v}), + other => json!({"value": other}), + } +} + /// Run a tool by name. pub fn run(b: &mut Backend, name: &str, args: &Value) -> Result { let t = find(name).ok_or_else(|| Error::BadArgs(format!("unknown tool `{name}`")))?; diff --git a/docs/mcp.md b/docs/mcp.md index 1104927ac..e2fca03be 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -42,6 +42,18 @@ read `results` / `items` for the details. Malformed JSON returns `-32700` with a null id and the session keeps serving. The MCP backend owns its session without a mutex; the engine's render-job mutexes already recover poisoned locks. +## Structured results + +Sessions that negotiated MCP 2025-06-18 (and modern per-request clients) get each JSON tool +result as `structuredContent` as well as the JSON text block older clients read. It is always an +object: an array result is `{"items": [...]}`, a scalar `{"value": v}`, and an image reply carries +its metadata (`comp`, `time`, `width`, `height`, `path?`). Tools with a stable shape declare it as +`outputSchema` in `tools/list`: `get_state`, `doc_inspect`, `render_preview`, `undo`, `redo`, +`history`, `command_batch`, `batch` and `save_project`. Earlier revisions see neither field. +Long renders (below) reply with text only. + +## Resources + Resources `effectcraft://document` and `effectcraft://commands` return JSON matching `doc_inspect` and `command_list`. Clients declaring MCP 2026-07-28 in per-request `_meta` receive `resultType: complete` and list/read cache hints; document reads are never cached.