mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-11 04:47:14 +00:00
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:
1 parent
e5d5ed80e9
commit
7d44027246
4 files changed
+231
-25
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user