AG2.8: MCP structured results: structuredContent and outputSchema for 2025-06-18+ clients

JSON tool results also come as structuredContent (always an object: arrays as {items},
scalars as {value}, image replies as their metadata) to sessions that negotiated 2025-06-18
or modern per-request clients; the text block stays for older clients. get_state,
doc_inspect, render_preview/render_frame, undo, redo, history, command_batch, batch and
save_project declare an outputSchema. The server now tracks the negotiated revision.
doc_inspect also carries project.autosave with --autosave.

(cherry picked from commit 5efa1038a5696a908b120468420afa92d88779d4)

(cherry picked from commit 780f497c9049ac06cc16a558c7c024adec20dbdb)
This commit is contained in:
Elias authored and Claude committed 2026-10-11 10:28:36 +00:00
1 parent e5d5ed80e9
commit 7d44027246
4 files changed
+231 -25

No files matched your search

+70 -25
View File
@@ -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<AutoSave>,
/// 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<Value> = tools::available(self.backend.is_bridge()).map(|t| t.descriptor()).collect();
let structured = self.structured(params);
let tools: Vec<Value> = 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::<Value>(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<Reply, Error>) -> 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<Reply, Error>, 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 {
+104
View File
@@ -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);
}
+45
View File
@@ -107,6 +107,51 @@ pub fn available(bridge: bool) -> impl Iterator<Item = &'static ToolDef> {
/// 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<Value> {
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<Reply> {
let t = find(name).ok_or_else(|| Error::BadArgs(format!("unknown tool `{name}`")))?;
+12
View File
@@ -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.