diff --git a/crates/op-cli/src/main.rs b/crates/op-cli/src/main.rs index 13604b426..b86627b4b 100644 --- a/crates/op-cli/src/main.rs +++ b/crates/op-cli/src/main.rs @@ -191,6 +191,10 @@ fn command_from_positionals(positionals: &[String], flags: &Flags) -> Result { + let file_path = required_pos(positionals, 1, "Usage: op save ")?; + tool_call("save_document", vec![pair("filePath", file_path)]) + } "get" => map_get(flags), "selection" => tool_call("get_selection", vec![]), "insert" => map_insert(positionals), @@ -222,7 +226,7 @@ fn command_from_positionals(positionals: &[String], flags: &Flags) -> Result { codegen_cli::map_codegen(positionals, flags) } - "start" | "stop" | "save" | "install" | "uninstall" => Err(format!( + "start" | "stop" | "install" | "uninstall" => Err(format!( "TS command {:?} is not implemented by the Rust HTTP MCP CLI yet", positionals[0] )), diff --git a/crates/op-cli/src/tests.rs b/crates/op-cli/src/tests.rs index 583d8224b..fdd7adc03 100644 --- a/crates/op-cli/src/tests.rs +++ b/crates/op-cli/src/tests.rs @@ -155,6 +155,18 @@ fn parse_args_maps_ts_open_to_open_document() { ); } +#[test] +fn parse_args_maps_ts_save_to_save_document_tool() { + let p = parse_args(&["save".to_string(), "/tmp/copy.op".to_string()]).expect("parse save"); + assert_eq!( + p.command, + Command::ToolCall { + tool: "save_document".to_string(), + args: vec![("filePath".to_string(), "/tmp/copy.op".to_string())], + } + ); +} + #[test] fn parse_args_maps_ts_page_list_alias_to_rust_tool() { let args = vec!["page".to_string(), "list".to_string()]; diff --git a/crates/op-host-desktop/src/mcp_serve.rs b/crates/op-host-desktop/src/mcp_serve.rs index 480f91be8..e1b6a84de 100644 --- a/crates/op-host-desktop/src/mcp_serve.rs +++ b/crates/op-host-desktop/src/mcp_serve.rs @@ -1,10 +1,8 @@ //! Stdio MCP server mode for the desktop binary. //! -//! When `openpencil-desktop --mcp ` is invoked, we skip the -//! winit event loop and run a JSON-RPC stdio server against the -//! `.op` file at ``. External CLIs (Claude Code / Codex / -//! Gemini / Copilot) can spawn the binary in this mode to drive -//! the Rust editor exactly the way they drive TS pen-mcp today. +//! `openpencil-desktop --mcp ` runs JSON-RPC stdio against a +//! `.op` file. External CLIs can spawn this mode to drive the Rust +//! editor the way they drive TS pen-mcp today. //! The server backs the `.op` file with an `op_editor_core:: //! EditorState` (the canonical `jian_ops_schema::PenDocument`), not //! the old shell-core `Document`. Loading a `.op` into a `PenDocument` @@ -41,18 +39,19 @@ use op_mcp::{ redo_snapshot, remove_node_effect_snapshot, remove_page_snapshot, rename_component_snapshot, rename_page_snapshot, rename_variable_snapshot, reorder_page_snapshot, reorder_selected_snapshot, replace_all_matching_properties_snapshot, replace_node_snapshot, - run_stdio_with_applier, save_theme_preset_snapshot, search_all_unique_properties_snapshot, - selection_snapshot, set_active_axis_value_snapshot, set_active_page_snapshot, - set_active_tool_snapshot, set_design_md_snapshot, set_ellipse_arc_snapshot, - set_node_collapsed_snapshot, set_node_corner_radius_snapshot, set_node_fill_hex_snapshot, - set_node_flip_snapshot, set_node_font_size_snapshot, set_node_font_weight_snapshot, - set_node_hidden_snapshot, set_node_locked_snapshot, set_node_name_snapshot, - set_node_rotation_snapshot, set_node_stroke_hex_snapshot, set_node_stroke_width_snapshot, - set_node_text_snapshot, set_selection_set_snapshot, set_selection_snapshot, - set_themes_snapshot, set_variable_boolean_snapshot, set_variable_color_snapshot, - set_variable_number_snapshot, set_variable_string_snapshot, set_variables_snapshot, - set_viewport_snapshot, snapshot_layout_snapshot, toggle_node_selection_snapshot, undo_snapshot, - ungroup_selected_snapshot, update_node_snapshot, ToolRegistry, + run_stdio_with_applier, save_document_snapshot, save_theme_preset_snapshot, + search_all_unique_properties_snapshot, selection_snapshot, set_active_axis_value_snapshot, + set_active_page_snapshot, set_active_tool_snapshot, set_design_md_snapshot, + set_ellipse_arc_snapshot, set_node_collapsed_snapshot, set_node_corner_radius_snapshot, + set_node_fill_hex_snapshot, set_node_flip_snapshot, set_node_font_size_snapshot, + set_node_font_weight_snapshot, set_node_hidden_snapshot, set_node_locked_snapshot, + set_node_name_snapshot, set_node_rotation_snapshot, set_node_stroke_hex_snapshot, + set_node_stroke_width_snapshot, set_node_text_snapshot, set_selection_set_snapshot, + set_selection_snapshot, set_themes_snapshot, set_variable_boolean_snapshot, + set_variable_color_snapshot, set_variable_number_snapshot, set_variable_string_snapshot, + set_variables_snapshot, set_viewport_snapshot, snapshot_layout_snapshot, + toggle_node_selection_snapshot, undo_snapshot, ungroup_selected_snapshot, update_node_snapshot, + ToolRegistry, }; /// Load a `.op` file into an `EditorState`. The `.op` format is plain @@ -373,6 +372,7 @@ fn rebuild_registry(doc: &EditorState) -> ToolRegistry { r.register(Box::new(tool)); } r.register(Box::new(open_document_snapshot(doc))); + r.register(Box::new(save_document_snapshot(doc))); r.register(Box::new(document_info_snapshot(doc))); r.register(Box::new(selection_snapshot(doc))); r.register(Box::new(get_node_snapshot(doc))); @@ -679,6 +679,7 @@ fn tools_list_response(id_raw: &str, state: &EditorState) -> String { const TOOL_SCHEMAS: &[&str] = &[ // --- read tools --- r#"{"name":"open_document","description":"Connect to the current Rust MCP document and return metadata, context summary, and design prompt. filePath is accepted for TS CLI compatibility; the Rust server remains bound to the document it was started with.","inputSchema":{"type":"object","properties":{"filePath":{"type":"string","description":"Accepted for TS compatibility; use live://canvas/current server document"}}}}"#, + r#"{"name":"save_document","description":"Save the current Rust MCP document snapshot to a .op file. Used by the Rust HTTP CLI to match TS `op save`.","inputSchema":{"type":"object","properties":{"filePath":{"type":"string","description":"Target .op file path"}},"required":["filePath"]}}"#, r#"{"name":"get_document_info","description":"Summarize the open document (page count, active page, etc).","inputSchema":{"type":"object","properties":{},"additionalProperties":false}}"#, r#"{"name":"get_selection","description":"Return the current selection state (ids, count).","inputSchema":{"type":"object","properties":{},"additionalProperties":false}}"#, r#"{"name":"get_node","description":"Read a node by id with depth-limited descendants.","inputSchema":{"type":"object","properties":{"node_id":{"type":"string","description":"u64 node id"}},"required":["node_id"]}}"#, diff --git a/crates/op-host-desktop/src/mcp_serve/tests.rs b/crates/op-host-desktop/src/mcp_serve/tests.rs index 26de51b57..52ba56d04 100644 --- a/crates/op-host-desktop/src/mcp_serve/tests.rs +++ b/crates/op-host-desktop/src/mcp_serve/tests.rs @@ -57,7 +57,7 @@ fn tools_list_response_includes_all_registered_tools() { // TOOL_SCHEMAS without being added to the list below. assert_eq!( TOOL_SCHEMAS.len(), - 105, + 106, "tools/list catalog count must match the registered tools — add the new tool to this test" ); // Production catalog excludes debug tools (we removed the @@ -90,6 +90,7 @@ fn tools_list_response_includes_all_registered_tools() { for name in [ "get_document_info", "open_document", + "save_document", "get_selection", "get_node", "list_pages", diff --git a/crates/op-mcp/src/document_save.rs b/crates/op-mcp/src/document_save.rs new file mode 100644 index 000000000..8593007bc --- /dev/null +++ b/crates/op-mcp/src/document_save.rs @@ -0,0 +1,55 @@ +//! Document save tool used by the Rust HTTP CLI transport. + +use std::collections::BTreeMap; +use std::path::PathBuf; + +use op_editor_core::EditorState; + +use super::{McpTool, ToolErrorCode, ToolOutcome}; + +pub struct SaveDocument { + document_json: String, +} + +impl McpTool for SaveDocument { + fn name(&self) -> &str { + "save_document" + } + + fn call(&self, args: &BTreeMap) -> ToolOutcome { + let Some(path) = args.get("filePath").filter(|path| !path.trim().is_empty()) else { + return ToolOutcome::Err( + ToolErrorCode::MissingArgument, + "filePath is required".into(), + ); + }; + let path = resolve_path(path); + if let Err(e) = std::fs::write(&path, &self.document_json) { + return ToolOutcome::Err( + ToolErrorCode::ToolFailed, + format!("save document failed: {e}"), + ); + } + let mut out = BTreeMap::new(); + out.insert("ok".into(), "true".into()); + out.insert("filePath".into(), path.display().to_string()); + ToolOutcome::Ok(out) + } +} + +pub fn save_document_snapshot(state: &EditorState) -> SaveDocument { + SaveDocument { + document_json: serde_json::to_string(&state.doc).unwrap_or_else(|_| "{}".into()), + } +} + +fn resolve_path(raw: &str) -> PathBuf { + let path = PathBuf::from(raw); + if path.is_absolute() { + path + } else { + std::env::current_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .join(path) + } +} diff --git a/crates/op-mcp/src/document_save_tests.rs b/crates/op-mcp/src/document_save_tests.rs new file mode 100644 index 000000000..1041a8653 --- /dev/null +++ b/crates/op-mcp/src/document_save_tests.rs @@ -0,0 +1,57 @@ +//! Document save tool tests. + +use std::collections::BTreeMap; +use std::path::PathBuf; + +use super::test_fixtures::sample; +use super::{save_document_snapshot, McpTool, ToolErrorCode, ToolOutcome}; + +#[test] +fn save_document_writes_current_pen_document_to_target_path() { + let state = sample(); + let dir = temp_dir("save-document"); + std::fs::create_dir_all(&dir).expect("temp dir"); + let out_path = dir.join("copy.op"); + + let tool = save_document_snapshot(&state); + let mut args = BTreeMap::new(); + args.insert("filePath".into(), out_path.display().to_string()); + + match tool.call(&args) { + ToolOutcome::Ok(out) => { + assert_eq!(out.get("ok"), Some(&"true".to_string())); + assert_eq!(out.get("filePath"), Some(&out_path.display().to_string())); + let saved: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&out_path).expect("saved document")) + .expect("saved document json"); + assert_eq!(saved["version"], state.doc.version); + assert!(saved["children"].is_array() || saved["pages"].is_array()); + } + other => panic!("expected save ok, got {other:?}"), + } + + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn save_document_requires_file_path() { + let tool = save_document_snapshot(&sample()); + match tool.call(&BTreeMap::new()) { + ToolOutcome::Err(code, message) => { + assert_eq!(code, ToolErrorCode::MissingArgument); + assert!(message.contains("filePath")); + } + other => panic!("expected missing argument, got {other:?}"), + } +} + +fn temp_dir(label: &str) -> PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos(); + std::env::temp_dir().join(format!( + "openpencil-document-save-{label}-{}-{nanos}", + std::process::id() + )) +} diff --git a/crates/op-mcp/src/lib.rs b/crates/op-mcp/src/lib.rs index 7dc3f73c6..fb58076ba 100644 --- a/crates/op-mcp/src/lib.rs +++ b/crates/op-mcp/src/lib.rs @@ -41,6 +41,9 @@ mod design_md_tools_tests; pub mod design_prompt; #[cfg(test)] mod design_prompt_tests; +pub mod document_save; +#[cfg(test)] +mod document_save_tests; pub mod element_tools; pub mod extra_read_tools; #[cfg(test)] @@ -126,6 +129,7 @@ pub use design_md_tools::{ GetDesignMd, SetDesignMd, }; pub use design_prompt::{get_design_prompt_snapshot, GetDesignPrompt}; +pub use document_save::{save_document_snapshot, SaveDocument}; pub use extra_read_tools::{get_node_children_snapshot, ChildRecord, GetNodeChildren}; pub use json_serializer::response_to_json; pub use node_attr_tools::{