mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-02 08:43:37 +00:00
- `figcraft-cli mcp --automation-read-root DIR --automation-write-root DIR` (each repeatable, `--flag=DIR` works too). When either is given, every file an agent's tool call reads must lie inside a read root or a write root, and every file it writes inside a write root. Anything else is an ordinary tool error (`automation path rejected: … is outside the folders that may be written: …`); nothing is written and the server keeps serving. Without the flags nothing changes.
- The desktop app takes the same flags (`figcraft --control PORT --automation-read-root DIR …`) and confines every control request and every frame of every window to them, so `figcraft-cli mcp --attach` drives a confined app. `--attach` together with the flags is a usage error, because the app runs the commands and only the app can confine them.
- New engine module `crates/engine/src/file_access.rs`: `Roots` (resolution and comparison), a thread-scoped `confined(roots, f)`, and the file primitives (`read`, `read_capped`, `open`, `metadata`, `read_dir`, `write` (atomic), `create_dir_all`, `exists`, `is_dir`). Every engine file access now goes through it. `crates/engine/clippy.toml` denies `std::fs::*`, `File::open/create`, `OpenOptions::open`, `Path::exists/is_file/is_dir/metadata/read_dir/canonicalize` and `figcraft_format::{write_file_atomic, save_file, load_file}` everywhere else in the engine, so a command added later is confined without a check of its own.
- `ui.screenshot` and `ui.render` return the PNG as base64 `data` when no `path` is given (before, `ui.screenshot` wrote `figcraft-screenshot.png` in the working directory and `ui.render` required a path). The attached MCP backend uses that, so it no longer writes and deletes temporary PNGs, and a confined app needs no exemption for them.
- `initialize`'s `instructions` name the folders the agent may read and write.
- `figcraft-cli` options may be given more than once (`Args::values`); single-valued options keep the last value, as before.
- Docs: `docs/mcp.md` › Confining file access (options, semantics, coverage, resolution, limits), `docs/control-protocol.md` › Confining file access and the optional `path` of `ui.screenshot` / `ui.render`, `docs/cli.md`, `figcraft-cli --help`, and the Automation line plus a revision row in `docs/target-app-parity.md`.
Fixes #715, fixes #481.
184 lines
7.6 KiB
Rust
184 lines
7.6 KiB
Rust
//! Where MCP tool calls end up: a control-channel method call, answered either by an in-process
|
|
//! engine session ([`crate::Headless`]) or by a running FigCraft app ([`Attached`]).
|
|
|
|
use std::io::{BufRead, BufReader, Write};
|
|
use std::net::{TcpStream, ToSocketAddrs};
|
|
use std::time::Duration;
|
|
|
|
use base64::Engine as _;
|
|
use serde_json::{Value, json};
|
|
|
|
/// Something that answers the FigCraft control-channel methods (`engine.execute`,
|
|
/// `engine.commands`, `document.inspect`, `node.get`, `ui.pointer`, `ui.key`, `ui.type`,
|
|
/// `ui.render`, `ui.screenshot`, `ui.inspect`). See `docs/control-protocol.md`.
|
|
pub trait Backend {
|
|
/// Call one method. `Ok` carries the `result`, `Err` the error message.
|
|
fn call(&mut self, method: &str, params: Value) -> Result<Value, String>;
|
|
|
|
/// True when a real UI window is attached (`ui.screenshot` captures the window).
|
|
fn has_ui(&self) -> bool;
|
|
|
|
/// Short human description ("headless", "attached to 127.0.0.1:7979").
|
|
fn describe(&self) -> String;
|
|
|
|
/// PNG bytes of the document: `ids` (in page coordinates, their union) or, when empty, the
|
|
/// current page's content. The default goes through `ui.render`, the PNG coming back as data.
|
|
fn render_png(&mut self, ids: &[u64], scale: f64) -> Result<Vec<u8>, String> {
|
|
let mut params = json!({"scale": scale});
|
|
if !ids.is_empty() {
|
|
params["ids"] = json!(ids);
|
|
} else {
|
|
// `ui.render` without ids exports the selection; pass the page's children instead.
|
|
let kids = page_children(self)?;
|
|
if kids.is_empty() {
|
|
return Err("the current page is empty: nothing to render".into());
|
|
}
|
|
params["ids"] = json!(kids);
|
|
}
|
|
decode_data(&self.call("ui.render", params)?)
|
|
}
|
|
|
|
/// PNG bytes of what the user sees: the app window when attached, the current viewport when
|
|
/// headless.
|
|
fn screenshot_png(&mut self) -> Result<Vec<u8>, String> {
|
|
capture(self, json!({}))
|
|
}
|
|
|
|
/// PNG bytes of the attached app's window `window` (an id from `window.list`).
|
|
fn window_png(&mut self, window: u64) -> Result<Vec<u8>, String> {
|
|
capture(self, json!({"window": window}))
|
|
}
|
|
}
|
|
|
|
/// A window capture, the PNG coming back as data (no file: an app confined to automation roots
|
|
/// writes none outside them).
|
|
fn capture<B: Backend + ?Sized>(b: &mut B, params: Value) -> Result<Vec<u8>, String> {
|
|
decode_data(&b.call("ui.screenshot", params)?)
|
|
}
|
|
|
|
/// The ids of the current page's top-level layers.
|
|
pub fn page_children<B: Backend + ?Sized>(b: &mut B) -> Result<Vec<u64>, String> {
|
|
let doc = b.call("document.inspect", json!({"depth": 1}))?;
|
|
let current = doc.get("currentPage").cloned().unwrap_or(Value::Null);
|
|
let pages = doc.get("pages").and_then(Value::as_array).cloned().unwrap_or_default();
|
|
let page = pages.iter().find(|p| p.get("id") == Some(¤t)).or_else(|| pages.first());
|
|
Ok(page
|
|
.and_then(|p| p.get("children"))
|
|
.and_then(Value::as_array)
|
|
.map(|a| a.iter().filter_map(|c| c.get("id").and_then(Value::as_u64)).collect())
|
|
.unwrap_or_default())
|
|
}
|
|
|
|
/// Decode a `data` field (base64, optionally a data: URL) from an export result.
|
|
pub fn decode_data(v: &Value) -> Result<Vec<u8>, String> {
|
|
let d = v.get("data").and_then(Value::as_str).ok_or("the export returned no data")?;
|
|
let d = d.split_once(',').map(|(_, b)| b).unwrap_or(d);
|
|
base64::engine::general_purpose::STANDARD.decode(d.trim()).map_err(|e| format!("bad base64 from the app: {e}"))
|
|
}
|
|
|
|
/// A running FigCraft app, reached through its loopback control port (`figcraft --control PORT`).
|
|
pub struct Attached {
|
|
addr: String,
|
|
conn: Option<(BufReader<TcpStream>, TcpStream)>,
|
|
next_id: u64,
|
|
}
|
|
|
|
impl Attached {
|
|
/// Connect to `addr` (e.g. `127.0.0.1:7979`), failing fast when nothing is listening.
|
|
pub fn connect(addr: &str) -> std::io::Result<Self> {
|
|
let mut r = Self { addr: addr.to_string(), conn: None, next_id: 1 };
|
|
r.reconnect()?;
|
|
Ok(r)
|
|
}
|
|
|
|
/// Connect to `127.0.0.1:port`.
|
|
pub fn port(port: u16) -> std::io::Result<Self> {
|
|
Self::connect(&format!("127.0.0.1:{port}"))
|
|
}
|
|
|
|
pub fn addr(&self) -> &str {
|
|
&self.addr
|
|
}
|
|
|
|
fn reconnect(&mut self) -> std::io::Result<()> {
|
|
self.conn = None;
|
|
let mut last = std::io::Error::new(std::io::ErrorKind::NotFound, format!("cannot resolve {}", self.addr));
|
|
for sa in self.addr.to_socket_addrs()? {
|
|
match TcpStream::connect_timeout(&sa, Duration::from_millis(800)) {
|
|
Ok(s) => {
|
|
s.set_nodelay(true).ok();
|
|
// The app answers within 60 s (its own timeout); leave headroom.
|
|
s.set_read_timeout(Some(Duration::from_secs(90))).ok();
|
|
let read = s.try_clone()?;
|
|
self.conn = Some((BufReader::new(read), s));
|
|
return Ok(());
|
|
}
|
|
Err(e) => last = e,
|
|
}
|
|
}
|
|
Err(last)
|
|
}
|
|
|
|
/// The app already closed this connection (it drops ones idle past its socket timeout), or
|
|
/// left bytes on it nobody asked for. Checked before a request is written, when replacing the
|
|
/// connection can't repeat anything.
|
|
fn stale(&mut self) -> bool {
|
|
let Some((reader, writer)) = self.conn.as_mut() else { return false };
|
|
if !reader.buffer().is_empty() || writer.set_nonblocking(true).is_err() {
|
|
return true;
|
|
}
|
|
// Nothing to read on a healthy idle connection: the peek would block.
|
|
let idle = matches!(writer.peek(&mut [0]), Err(e) if e.kind() == std::io::ErrorKind::WouldBlock);
|
|
writer.set_nonblocking(false).is_err() || !idle
|
|
}
|
|
|
|
fn roundtrip(&mut self, line: &str) -> std::io::Result<String> {
|
|
let Some((reader, writer)) = self.conn.as_mut() else {
|
|
return Err(std::io::Error::new(std::io::ErrorKind::NotConnected, "not connected to the app"));
|
|
};
|
|
writer.write_all(line.as_bytes())?;
|
|
writer.write_all(b"\n")?;
|
|
writer.flush()?;
|
|
let mut reply = String::new();
|
|
if reader.read_line(&mut reply)? == 0 {
|
|
return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "control channel closed"));
|
|
}
|
|
Ok(reply)
|
|
}
|
|
}
|
|
|
|
impl Backend for Attached {
|
|
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
|
|
let id = self.next_id;
|
|
self.next_id += 1;
|
|
let line = json!({"id": id, "method": method, "params": params}).to_string();
|
|
if self.conn.is_none() || self.stale() {
|
|
self.reconnect().map_err(|e| {
|
|
format!("FigCraft app at {} is not reachable: {e} (request was not sent; start it with `figcraft --control PORT`)", self.addr)
|
|
})?;
|
|
}
|
|
// A lost reply does not mean the app failed to apply the request.
|
|
let reply = self.roundtrip(&line).map_err(|e| {
|
|
self.conn = None;
|
|
format!(
|
|
"FigCraft control call `{method}` at {} failed: {e}; the operation may have completed. Inspect the document before retrying.",
|
|
self.addr
|
|
)
|
|
})?;
|
|
let v: Value = serde_json::from_str(reply.trim()).map_err(|e| format!("bad reply from the app: {e}"))?;
|
|
if v.get("ok").and_then(Value::as_bool) == Some(true) {
|
|
Ok(v.get("result").cloned().unwrap_or(Value::Null))
|
|
} else {
|
|
Err(v.get("error").and_then(Value::as_str).unwrap_or("unknown error").to_string())
|
|
}
|
|
}
|
|
|
|
fn has_ui(&self) -> bool {
|
|
true
|
|
}
|
|
|
|
fn describe(&self) -> String {
|
|
format!("attached to the FigCraft app at {}", self.addr)
|
|
}
|
|
}
|