mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-30 05:17:25 -06:00
* Engine: check automation read and write roots on every path a command reads or writes (#769) * MCP: confine the headless session to the automation roots (#769) * CLI: add --automation-read-root and --automation-write-root to mcp (#769) * Docs: document --automation-read-root and --automation-write-root for mcp (#769) * Review fixes for #798: confine file.printPdf; guard all of engine/src - print.rs::print_pdf_to_file (file.printPdf, added on main) wrote with std::fs::write directly, so a confined agent could print a PDF anywhere. It now goes through io::write_file, which checks the automation write root. - The guard test now walks every file under crates/engine/src (not only cmd/), except io.rs, file_access.rs and the tests*.rs modules, and also flags OpenOptions. - Tests: an engine test that file.printPdf is refused outside the write root, works inside and is unchanged without roots; the MCP integration test covers it too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Review fixes for #798: list file.printPdf among the confined writes in docs/mcp.md Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Font files added by path go through io, inside the automation read root (#769) --------- Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
86 lines
3.4 KiB
Rust
86 lines
3.4 KiB
Rust
//! WordCraft's MCP server (Model Context Protocol, JSON-RPC 2.0 over stdio).
|
|
//!
|
|
//! Two backends: [`Headless`] runs an in-process editing session (no window); [`backend::Remote`]
|
|
//! talks to a running WordCraft app through its control channel (`wordcraft --control PORT`), so
|
|
//! agents can also click, type and screenshot the real UI.
|
|
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
|
|
|
pub mod backend;
|
|
pub mod server;
|
|
pub mod tools;
|
|
|
|
pub use backend::{Backend, Remote};
|
|
pub use server::Server;
|
|
|
|
use std::sync::Arc;
|
|
|
|
use serde_json::{Value, json};
|
|
use wordcraft_engine::Session;
|
|
use wordcraft_engine::file_access::{self, AutomationRoots};
|
|
|
|
/// Methods that need the desktop app.
|
|
pub const NEEDS_APP: &str = "needs the desktop app: start `wordcraft --control 7981` and run `wordcraft-cli mcp --connect 127.0.0.1:7981`";
|
|
|
|
/// An in-process session.
|
|
pub struct Headless {
|
|
pub session: Session,
|
|
roots: Option<Arc<AutomationRoots>>,
|
|
}
|
|
|
|
impl Headless {
|
|
/// Confine every file the session's commands read and write to `roots`
|
|
/// (`wordcraft-cli mcp --automation-read-root … --automation-write-root …`).
|
|
pub fn with_automation_roots(mut self, roots: Option<Arc<AutomationRoots>>) -> Self {
|
|
self.roots = roots;
|
|
self
|
|
}
|
|
|
|
fn answer(&mut self, method: &str, params: Value) -> Result<Value, String> {
|
|
match method {
|
|
"engine.execute" | "command" => {
|
|
let id = params.get("command").and_then(Value::as_str).ok_or("missing `command`")?.to_string();
|
|
let p = params.get("params").cloned().filter(|v| !v.is_null()).unwrap_or(json!({}));
|
|
self.session.run(&id, &p).map_err(|e| e.to_string())
|
|
}
|
|
"engine.commands" => Ok(self.session.registry.describe()),
|
|
"document.inspect" => self.session.run("document.inspect", ¶ms).map_err(|e| e.to_string()),
|
|
"ui.parity" => Ok(wordcraft_engine::catalog::parity(&self.session.registry)),
|
|
"ui.render" | "render.page" => {
|
|
let page = params.get("page").and_then(Value::as_u64).unwrap_or(1).max(1) as usize - 1;
|
|
let scale = params.get("scale").and_then(Value::as_f64).unwrap_or(1.0).clamp(0.1, 4.0) as f32;
|
|
let l = self.session.layout();
|
|
let pg = l.pages.get(page).ok_or_else(|| format!("no page {}", page + 1))?;
|
|
let img = wordcraft_render::render_page(&self.session.doc, pg, scale, &Default::default());
|
|
let png = img.to_png();
|
|
Ok(
|
|
json!({"png": wordcraft_engine::cmd::insert::base64_encode(&png), "width": img.width, "height": img.height, "pages": l.pages.len()}),
|
|
)
|
|
}
|
|
m if m.starts_with("ui.") => Err(format!("`{m}` {NEEDS_APP}")),
|
|
other => self.session.run(other, ¶ms).map_err(|e| e.to_string()),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Default for Headless {
|
|
fn default() -> Self {
|
|
Headless { session: Session::new(wordcraft_doc::Document::new()), roots: None }
|
|
}
|
|
}
|
|
|
|
impl Backend for Headless {
|
|
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
|
|
let roots = self.roots.clone();
|
|
file_access::confine(roots.as_ref(), || self.answer(method, params))
|
|
}
|
|
fn has_ui(&self) -> bool {
|
|
false
|
|
}
|
|
fn describe(&self) -> String {
|
|
"headless session (no window)".into()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests;
|