mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-25 10:56:48 -06:00
The attached MCP backend no longer re-sends a request when its reply is lost: the app may already have applied it, so the error says the operation may have completed and to inspect the document before retrying; the next call reconnects, and a failed reconnect says the request was not sent. Fixes #194. Also: a connection the app already closed (it drops ones idle past its socket timeout) is noticed before the next request is written and replaced then, so a pause between calls is not an error. Co-authored-by: bflatastic <bflatastic@users.noreply.github.com>
150 lines
7.2 KiB
Rust
150 lines
7.2 KiB
Rust
//! The attached backend against a fake app: a loopback control server answering with a headless
|
|
//! session, speaking the desktop app's JSON-lines protocol.
|
|
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
|
|
|
use std::io::{BufRead, BufReader, Write};
|
|
use std::net::TcpListener;
|
|
|
|
use figcraft_mcp::{Attached, Backend, Headless, Server};
|
|
use serde_json::{Value, json};
|
|
|
|
fn fake_app() -> u16 {
|
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
|
let port = listener.local_addr().unwrap().port();
|
|
std::thread::spawn(move || {
|
|
let mut h = Headless::new();
|
|
for stream in listener.incoming().flatten() {
|
|
let mut out = stream.try_clone().unwrap();
|
|
for line in BufReader::new(stream).lines() {
|
|
let Ok(line) = line else { break };
|
|
let v: Value = serde_json::from_str(&line).unwrap_or(Value::Null);
|
|
let method = v["method"].as_str().unwrap_or("");
|
|
let reply = match h.call(method, v["params"].clone()) {
|
|
Ok(r) => json!({"id": v["id"], "ok": true, "result": r}),
|
|
Err(e) => json!({"id": v["id"], "ok": false, "error": e}),
|
|
};
|
|
if writeln!(out, "{reply}").is_err() {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
port
|
|
}
|
|
|
|
fn call(s: &mut Server, tool: &str, args: Value) -> Value {
|
|
let line = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": tool, "arguments": args}}).to_string();
|
|
let r: Value = serde_json::from_str(&s.handle_line(&line).unwrap()).unwrap();
|
|
r["result"].clone()
|
|
}
|
|
|
|
#[test]
|
|
fn attached_backend_forwards_everything() {
|
|
let port = fake_app();
|
|
let backend = Attached::port(port).unwrap();
|
|
assert!(backend.describe().contains(&port.to_string()));
|
|
let mut s = Server::new(Box::new(backend));
|
|
let r = call(&mut s, "create_node", json!({"type": "frame", "width": 64, "height": 32, "props": {"fill": "#FF0000"}}));
|
|
assert_eq!(r["isError"], false, "{r}");
|
|
let created: Value = serde_json::from_str(r["content"][0]["text"].as_str().unwrap()).unwrap();
|
|
let id = created["id"].clone();
|
|
// Render through ui.render + a temp file.
|
|
let shot = call(&mut s, "screenshot", json!({}));
|
|
assert_eq!(shot["content"][0]["type"], "image", "{shot}");
|
|
let shot = call(&mut s, "screenshot", json!({"ids": [id], "scale": 2}));
|
|
assert_eq!(shot["isError"], false, "{shot}");
|
|
// Window capture through ui.screenshot.
|
|
let win = call(&mut s, "screenshot", json!({"window": true}));
|
|
assert_eq!(win["content"][0]["type"], "image", "{win}");
|
|
let e = call(&mut s, "export", json!({"ids": [id]}));
|
|
assert_eq!(e["content"][0]["mimeType"], "image/png");
|
|
assert!(call(&mut s, "key", json!({"key": "Delete"}))["isError"] == false);
|
|
let doc = call(&mut s, "inspect_document", json!({}));
|
|
assert!(!doc["content"][0]["text"].as_str().unwrap().contains("\"Frame"));
|
|
let err = call(&mut s, "run_command", json!({"command": "nope"}));
|
|
assert_eq!(err["isError"], true);
|
|
}
|
|
|
|
#[test]
|
|
fn attached_to_nothing_fails_cleanly() {
|
|
let port = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
|
|
assert!(Attached::port(port).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn lost_mutation_reply_is_not_replayed_and_next_call_reconnects() {
|
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
|
let port = listener.local_addr().unwrap().port();
|
|
let app = std::thread::spawn(move || {
|
|
let mut h = Headless::new();
|
|
let mut methods = Vec::new();
|
|
for lose_reply in [true, false] {
|
|
let (mut stream, _) = listener.accept().unwrap();
|
|
stream.set_read_timeout(Some(std::time::Duration::from_secs(5))).unwrap();
|
|
let mut line = String::new();
|
|
BufReader::new(stream.try_clone().unwrap()).read_line(&mut line).unwrap();
|
|
let request: Value = serde_json::from_str(&line).unwrap();
|
|
let method = request["method"].as_str().unwrap();
|
|
methods.push(method.to_string());
|
|
let result = h.call(method, request["params"].clone()).unwrap();
|
|
if !lose_reply {
|
|
writeln!(stream, "{}", json!({"id": request["id"], "ok": true, "result": result})).unwrap();
|
|
}
|
|
}
|
|
methods
|
|
});
|
|
let mut backend = Attached::port(port).unwrap();
|
|
let result = backend.call("engine.execute", json!({"command": "shape.rectangle", "params": {"width": 64, "height": 32}}));
|
|
assert!(result.is_err(), "a lost reply must not turn into a successful replay: {result:?}");
|
|
let error = result.unwrap_err();
|
|
assert!(error.contains("may have completed"), "{error}");
|
|
assert!(error.contains("before retrying"), "{error}");
|
|
let doc = backend.call("document.inspect", json!({"depth": 1})).unwrap();
|
|
assert_eq!(doc["pages"][0]["children"].as_array().unwrap().len(), 1, "{doc}");
|
|
assert_eq!(app.join().unwrap(), ["engine.execute", "document.inspect"]);
|
|
}
|
|
|
|
#[test]
|
|
fn a_connection_the_app_closed_while_idle_is_replaced_before_sending() {
|
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
|
let port = listener.local_addr().unwrap().port();
|
|
let (closed_tx, closed_rx) = std::sync::mpsc::channel();
|
|
let app = std::thread::spawn(move || {
|
|
let mut h = Headless::new();
|
|
let mut methods = Vec::new();
|
|
// Each connection answers one request, then the app drops it as it does an idle one.
|
|
for _ in 0..2 {
|
|
let (mut stream, _) = listener.accept().unwrap();
|
|
stream.set_read_timeout(Some(std::time::Duration::from_secs(5))).unwrap();
|
|
let mut line = String::new();
|
|
BufReader::new(stream.try_clone().unwrap()).read_line(&mut line).unwrap();
|
|
let request: Value = serde_json::from_str(&line).unwrap();
|
|
let method = request["method"].as_str().unwrap();
|
|
methods.push(method.to_string());
|
|
let result = h.call(method, request["params"].clone()).unwrap();
|
|
writeln!(stream, "{}", json!({"id": request["id"], "ok": true, "result": result})).unwrap();
|
|
drop(stream);
|
|
closed_tx.send(()).unwrap();
|
|
}
|
|
methods
|
|
});
|
|
let mut backend = Attached::port(port).unwrap();
|
|
backend.call("engine.execute", json!({"command": "shape.rectangle", "params": {"width": 64, "height": 32}})).unwrap();
|
|
closed_rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
|
|
std::thread::sleep(std::time::Duration::from_millis(100));
|
|
// No error, nothing sent twice: the request goes out once, on a fresh connection.
|
|
let doc = backend.call("document.inspect", json!({"depth": 1})).unwrap();
|
|
assert_eq!(doc["pages"][0]["children"].as_array().unwrap().len(), 1, "{doc}");
|
|
assert_eq!(app.join().unwrap(), ["engine.execute", "document.inspect"]);
|
|
}
|
|
|
|
#[test]
|
|
fn reconnect_failure_reports_request_not_sent() {
|
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
|
let mut backend = Attached::port(listener.local_addr().unwrap().port()).unwrap();
|
|
drop(listener);
|
|
assert!(backend.call("document.inspect", json!({})).is_err());
|
|
let error = backend.call("document.inspect", json!({})).unwrap_err();
|
|
assert!(error.contains("request was not sent"), "{error}");
|
|
}
|