From b8b1b4ed7e26d07d03ed28edff489d131b7c5f86 Mon Sep 17 00:00:00 2001 From: Kayshen-X Date: Tue, 11 Aug 2026 08:11:39 +0800 Subject: [PATCH] feat(mcp): expose batch frame export and the deck board list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exporting every frame on a page meant calling export_item once per frame and inventing file names; the editor's own batch has resolved collisions, capped long AI-authored names and skipped hidden frames since v0.8.3. export_frames reuses plan_frame_exports rather than re-deriving any of it, so an agent writes what the Export panel writes. Partial failure is reported, not raised: one unrenderable frame must not throw away the files that did land. get_deck_boards is the read half. Slideshow control is deliberately absent: entering preview is a host mode transition rather than document state, and a file-backed MCP session has no window to present in. The board list is the part an agent can act on — verifying a deck before exporting one. --- crates/op-cli/src/cli_export_tests.rs | 27 ++++ crates/op-cli/src/export_cli.rs | 43 ++++++ crates/op-cli/src/main.rs | 8 ++ crates/op-cli/src/usage.txt | 3 + crates/op-host-services/src/mcp_serve.rs | 4 + .../src/mcp_serve/export_frames_tool.rs | 123 ++++++++++++++++++ .../op-host-services/src/mcp_serve/schemas.rs | 2 + .../op-host-services/src/mcp_serve/tests.rs | 55 +++++++- .../src/mcp_serve/tool_profile.rs | 7 + .../src/mcp_serve/tool_profile_tests.rs | 1 + 10 files changed, 272 insertions(+), 1 deletion(-) create mode 100644 crates/op-host-services/src/mcp_serve/export_frames_tool.rs diff --git a/crates/op-cli/src/cli_export_tests.rs b/crates/op-cli/src/cli_export_tests.rs index 3fc4971db..184549450 100644 --- a/crates/op-cli/src/cli_export_tests.rs +++ b/crates/op-cli/src/cli_export_tests.rs @@ -256,3 +256,30 @@ fn styles_takes_a_bare_id_or_filters() { Command::Styles { platform: Some(ref platform), .. } if platform == "slides" )); } + +#[test] +fn export_frames_requires_a_directory_and_rejects_pdf() { + let parsed = parse_args(&args(&["export-frames", "--output-dir", "/tmp/frames"])) + .expect("parse export-frames"); + assert!(matches!( + parsed.command, + Command::ExportFrames { ref format, .. } if format == "png" + )); + + assert!(parse_args(&args(&["export-frames"])) + .unwrap_err() + .to_string() + .contains("--output-dir")); + + // A frame batch is images; pdf belongs to `op export` / `op export-deck`. + assert!(parse_args(&args(&[ + "export-frames", + "--output-dir", + "/tmp/frames", + "--format", + "pdf", + ])) + .unwrap_err() + .to_string() + .contains("unsupported frame format")); +} diff --git a/crates/op-cli/src/export_cli.rs b/crates/op-cli/src/export_cli.rs index c65ef8d41..5c13b90ae 100644 --- a/crates/op-cli/src/export_cli.rs +++ b/crates/op-cli/src/export_cli.rs @@ -148,3 +148,46 @@ pub(crate) fn write_export_response(response: &str, output: &Path) -> Result Result { + let format = flag_value(flags, "format").unwrap_or_else(|| "png".into()); + if !matches!(format.as_str(), "png" | "jpeg" | "jpg" | "webp") { + return Err(CliError::Usage(format!( + "unsupported frame format {format:?}: must be one of png, jpeg, webp" + ))); + } + let output_dir = flag_value(flags, "output-dir") + .or_else(|| flag_value(flags, "output")) + .ok_or_else(|| CliError::usage("--output-dir is required"))?; + Ok(Command::ExportFrames { output_dir, format }) +} + +/// Batch-export every top-level frame. Like `run_export_deck`, the daemon +/// does the writing, so a relative directory is resolved against the caller's +/// shell rather than the daemon's working directory. +pub(crate) fn run_export_frames( + port: u16, + token: &str, + output_dir: &str, + format: &str, +) -> Result { + let directory = Path::new(output_dir); + let absolute = if directory.is_absolute() { + directory.to_path_buf() + } else { + std::env::current_dir() + .map_err(|error| CliError::Usage(format!("cannot resolve --output-dir: {error}")))? + .join(directory) + }; + let mut arguments = serde_json::Map::new(); + arguments.insert("format".into(), Value::String(format.into())); + arguments.insert( + "outputDir".into(), + Value::String(absolute.display().to_string()), + ); + post( + port, + token, + &tool_call_body("export_frames", &Value::Object(arguments).to_string()), + ) +} diff --git a/crates/op-cli/src/main.rs b/crates/op-cli/src/main.rs index 1645d6c3c..cfe3b1973 100644 --- a/crates/op-cli/src/main.rs +++ b/crates/op-cli/src/main.rs @@ -139,6 +139,9 @@ fn run(args: &[String]) -> Result { Command::UseTemplate { template_id } => { template_cli::run_use_template(target_port, &target_token, &template_id)? } + Command::ExportFrames { output_dir, format } => { + export_cli::run_export_frames(target_port, &target_token, &output_dir, &format)? + } Command::Styles { id, tag, platform } => template_cli::run_styles( target_port, &target_token, @@ -247,6 +250,10 @@ enum Command { tag: Option, platform: Option, }, + ExportFrames { + output_dir: String, + format: String, + }, } type Flags = BTreeMap>; @@ -375,6 +382,7 @@ fn command_from_positionals(positionals: &[String], flags: &Flags) -> Result template_cli::map_templates(flags), "use-template" => template_cli::map_use_template(flags, positionals), "styles" => template_cli::map_styles(flags, positionals), + "export-frames" => export_cli::map_export_frames(flags), "skill:export" => Ok(Command::SkillExport { name: required_pos( positionals, diff --git a/crates/op-cli/src/usage.txt b/crates/op-cli/src/usage.txt index e7f05d67b..5ba5d943c 100644 --- a/crates/op-cli/src/usage.txt +++ b/crates/op-cli/src/usage.txt @@ -28,6 +28,9 @@ COMMON COMMANDS: export the active page's boards as a presentation deck: PowerPoint, self- contained HTML, or slide-per-page PDF + op export-frames --output-dir DIR [--format png|jpeg|webp] + export every top-level frame on the + active page, one image per frame op templates [--scene S] [--tag T] list shipped scene templates (tutorial / comparison / carousel / slides / …), including 16:9 decks diff --git a/crates/op-host-services/src/mcp_serve.rs b/crates/op-host-services/src/mcp_serve.rs index c5f5ef340..9bdb05592 100644 --- a/crates/op-host-services/src/mcp_serve.rs +++ b/crates/op-host-services/src/mcp_serve.rs @@ -641,6 +641,8 @@ fn rebuild_registry(doc: &EditorState, requested_tool: Option<&str>) -> ToolRegi register_tool!("export_item", export_item_snapshot(doc)); register_tool!("export_nodes", export_nodes_snapshot(doc)); register_tool!("export_deck", export_deck_snapshot(doc)); + register_tool!("export_frames", export_frames_snapshot(doc)); + register_tool!("get_deck_boards", get_deck_boards_snapshot(doc)); register_tool!("list_scene_templates", list_scene_templates_snapshot()); register_tool!("use_scene_template", use_scene_template_snapshot()); register_tool!("get_active_theme", get_active_theme_snapshot(doc)); @@ -794,6 +796,8 @@ pub(crate) mod export_item_tool; use export_item_tool::export_item_snapshot; pub(crate) mod export_deck_tool; use export_deck_tool::export_deck_snapshot; +pub(crate) mod export_frames_tool; +use export_frames_tool::{export_frames_snapshot, get_deck_boards_snapshot}; pub(crate) mod scene_template_tools; use scene_template_tools::{list_scene_templates_snapshot, use_scene_template_snapshot}; diff --git a/crates/op-host-services/src/mcp_serve/export_frames_tool.rs b/crates/op-host-services/src/mcp_serve/export_frames_tool.rs new file mode 100644 index 000000000..8d4563292 --- /dev/null +++ b/crates/op-host-services/src/mcp_serve/export_frames_tool.rs @@ -0,0 +1,123 @@ +//! MCP tools `export_frames` and `get_deck_boards`. +//! +//! `export_frames` writes every top-level frame on the active page into a +//! directory, one file each — the batch the desktop's Export panel has run +//! since `v0.8.3`, which an agent previously had to imitate by calling +//! `export_item` once per frame and inventing its own file names. +//! +//! Naming and filtering are not re-derived here: `plan_frame_exports` already +//! resolves collisions, caps long AI-authored names, and skips hidden frames +//! (the exporter paints them empty, so including them would only manufacture +//! failures). Re-implementing any of that would drift from what the panel +//! writes. +//! +//! `get_deck_boards` is the read half. Slideshow *control* is deliberately not +//! here: entering preview is a host mode transition, not document state, and a +//! file-backed MCP session has no window to present in. What an agent can use +//! is the board list a deck export will walk, so it can verify the deck before +//! writing one. + +use std::collections::BTreeMap; +use std::path::PathBuf; + +use op_editor_core::EditorState; +use op_mcp::{McpTool, ToolErrorCode, ToolOutcome}; + +use crate::export_batch::export_frames_to_dir; + +pub struct ExportFrames { + state: EditorState, +} + +impl McpTool for ExportFrames { + fn name(&self) -> &str { + "export_frames" + } + + fn call(&self, args: &BTreeMap) -> ToolOutcome { + let directory = match args.get("outputDir").map(|value| value.trim()) { + Some(directory) if !directory.is_empty() => PathBuf::from(directory), + _ => { + return ToolOutcome::Err( + ToolErrorCode::MissingArgument, + "outputDir is required — export_frames writes one file per frame".into(), + ); + } + }; + let extension = args + .get("format") + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + .unwrap_or("png"); + if !matches!(extension, "png" | "jpeg" | "jpg" | "webp") { + return ToolOutcome::Err( + ToolErrorCode::InvalidArgument, + format!("unknown format {extension:?}: must be one of png, jpeg, webp"), + ); + } + + let targets = op_editor_core::export_batch::plan_frame_exports(&self.state, extension); + if targets.is_empty() { + return ToolOutcome::Err( + ToolErrorCode::InvalidArgument, + "the active page has no exportable top-level frames".into(), + ); + } + if let Err(error) = std::fs::create_dir_all(&directory) { + return ToolOutcome::Err( + ToolErrorCode::Internal, + format!("cannot create {}: {error}", directory.display()), + ); + } + + let report = export_frames_to_dir(&self.state, &directory, &targets); + // Partial success is reported, not raised: one unrenderable frame must + // not throw away the files that did land. + let failed: Vec = report + .failed + .iter() + .map(|(file_name, detail)| serde_json::json!({ "file": file_name, "error": detail })) + .collect(); + ToolOutcome::OkJson( + serde_json::json!({ + "directory": directory.display().to_string(), + "format": extension, + "attempted": report.attempted(), + "written": report.written, + "failed": failed, + }) + .to_string(), + ) + } +} + +pub struct GetDeckBoards { + state: EditorState, +} + +impl McpTool for GetDeckBoards { + fn name(&self) -> &str { + "get_deck_boards" + } + + fn call(&self, _args: &BTreeMap) -> ToolOutcome { + let boards = op_editor_core::preview_slideshow::active_page_boards(&self.state); + ToolOutcome::OkJson( + serde_json::json!({ "count": boards.len(), "boards": boards }).to_string(), + ) + } +} + +/// Registration is gated on the requested tool, so these clones happen only +/// when the tool being invoked is this one. +pub fn export_frames_snapshot(state: &EditorState) -> ExportFrames { + ExportFrames { + state: state.clone(), + } +} + +pub fn get_deck_boards_snapshot(state: &EditorState) -> GetDeckBoards { + GetDeckBoards { + state: state.clone(), + } +} diff --git a/crates/op-host-services/src/mcp_serve/schemas.rs b/crates/op-host-services/src/mcp_serve/schemas.rs index e448a55e3..1f943e3ed 100644 --- a/crates/op-host-services/src/mcp_serve/schemas.rs +++ b/crates/op-host-services/src/mcp_serve/schemas.rs @@ -32,6 +32,8 @@ pub const TOOL_SCHEMAS: &[&str] = &[ r#"{"name":"export_item","description":"Export a page, arbitrary node, or current selection to base64-encoded image/PDF bytes.","inputSchema":{"type":"object","properties":{"itemId":{"type":"string"},"format":{"type":"string","enum":["png","jpeg","webp","pdf"]},"scale":{"type":"number"}},"required":["format"]}}"#, r#"{"name":"export_nodes","description":"Export one or more nodes to base64-encoded image/PDF bytes. format is one of png|jpeg|webp|pdf.","inputSchema":{"type":"object","properties":{"nodeIds":{"type":"array","items":{"type":"string"}},"format":{"type":"string","enum":["png","jpeg","webp","pdf"]},"scale":{"type":"number"}},"required":["nodeIds","format"]}}"#, r#"{"name":"export_deck","description":"Export the active page's boards as a presentation deck in PowerPoint, self-contained HTML, or PDF. Writes a file at outputPath; use export_item/export_nodes for base64 node-level exports.","inputSchema":{"type":"object","properties":{"format":{"type":"string","enum":["pptx","html","pdf"]},"outputPath":{"type":"string","description":"Destination file path for the deck"}},"required":["format","outputPath"]}}"#, + r#"{"name":"export_frames","description":"Export every top-level frame on the active page into a directory, one image per frame, using the editor's own file naming.","inputSchema":{"type":"object","properties":{"outputDir":{"type":"string","description":"Destination directory; created if absent"},"format":{"type":"string","enum":["png","jpeg","webp"],"default":"png"}},"required":["outputDir"]}}"#, + r#"{"name":"get_deck_boards","description":"List the boards on the active page in slide order — what a deck export will walk. Hidden boards are omitted.","inputSchema":{"type":"object","properties":{},"additionalProperties":false}}"#, r#"{"name":"list_scene_templates","description":"List the shipped scene templates, including the 16:9 presentation deck templates. Optionally filter by scene or tag.","inputSchema":{"type":"object","properties":{"scene":{"type":"string","description":"Scene filter, e.g. slides, tutorial, comparison, carousel"},"tag":{"type":"string","description":"Tag filter"}}}}"#, r#"{"name":"use_scene_template","description":"Start from a shipped scene template. On an untouched starter page the template takes the page over; otherwise its boards are appended to the right. Call list_scene_templates for ids.","inputSchema":{"type":"object","properties":{"templateId":{"type":"string"}},"required":["templateId"]}}"#, r#"{"name":"get_active_theme","description":"Return the active theme axis pinning per axis.","inputSchema":{"type":"object","properties":{},"additionalProperties":false}}"#, diff --git a/crates/op-host-services/src/mcp_serve/tests.rs b/crates/op-host-services/src/mcp_serve/tests.rs index 098d97190..b24a971cf 100644 --- a/crates/op-host-services/src/mcp_serve/tests.rs +++ b/crates/op-host-services/src/mcp_serve/tests.rs @@ -24,7 +24,7 @@ fn tools_list_response_includes_all_registered_tools() { // TOOL_SCHEMAS without being added to the list below. assert_eq!( TOOL_SCHEMAS.len(), - 127, + 129, "tools/list catalog count must match the registered tools — add the new tool to this test" ); // Production catalog excludes debug tools (we removed the @@ -523,3 +523,56 @@ fn set_themes_accepts_structured_mcp_arguments_and_mutates_state() { #[path = "tests_transport.rs"] mod transport; + +#[test] +fn export_frames_writes_one_image_per_top_level_frame() { + use op_mcp::{McpTool as _, ToolOutcome}; + use std::collections::BTreeMap; + + let mut state = op_editor_core::EditorState::new(); + for (index, name) in ["Cover", "Agenda"].iter().enumerate() { + state.apply(op_editor_core::EditorCommand::InsertNode { + kind: "frame".into(), + name: (*name).into(), + x: (index as i32) * 400, + y: 0, + width: 320, + height: 180, + // A frame with no fill paints nothing and the exporter refuses + // it, which is the behaviour the failure branch below covers. + fill_hex: Some("#ffffff".into()), + target_parent: op_editor_core::NodeId::NONE, + page_id: None, + }); + } + + let directory = std::env::temp_dir().join("op-mcp-export-frames-test"); + let _ = std::fs::remove_dir_all(&directory); + let mut args = BTreeMap::new(); + args.insert("outputDir".to_string(), directory.display().to_string()); + + let outcome = super::export_frames_tool::export_frames_snapshot(&state).call(&args); + let ToolOutcome::OkJson(json) = outcome else { + panic!("unexpected outcome: {outcome:?}"); + }; + let report: serde_json::Value = serde_json::from_str(&json).expect("valid JSON"); + assert_eq!(report["attempted"].as_u64(), Some(2), "{json}"); + assert!( + report["failed"].as_array().is_some_and(Vec::is_empty), + "{json}" + ); + + // The report is only a claim until the files are on disk. + let written = report["written"].as_array().expect("written array"); + assert_eq!(written.len(), 2); + for entry in written { + let path = directory.join(entry.as_str().expect("file name")); + assert!( + path.is_file(), + "{} was reported but not written", + path.display() + ); + assert!(std::fs::metadata(&path).expect("stat").len() > 0); + } + let _ = std::fs::remove_dir_all(&directory); +} diff --git a/crates/op-host-services/src/mcp_serve/tool_profile.rs b/crates/op-host-services/src/mcp_serve/tool_profile.rs index ba4c32ce6..3a7fef153 100644 --- a/crates/op-host-services/src/mcp_serve/tool_profile.rs +++ b/crates/op-host-services/src/mcp_serve/tool_profile.rs @@ -375,6 +375,13 @@ pub const TOOL_PROFILES: &[ToolProfile] = &[ // Writes a deck file at a caller-chosen path, so it is a local-filesystem // surface rather than an in-memory one: a hosted tenant must not be able // to place bytes anywhere on the daemon host. + // Writes a directory of images at a caller-chosen path. + ToolProfile::new( + "export_frames", + ToolAccess::Read, + ToolSurface::LocalFilesystem, + ), + ToolProfile::new("get_deck_boards", ToolAccess::Read, ToolSurface::InMemory), ToolProfile::new( "export_deck", ToolAccess::Read, diff --git a/crates/op-host-services/src/mcp_serve/tool_profile_tests.rs b/crates/op-host-services/src/mcp_serve/tool_profile_tests.rs index ee62e2bb8..bbefc41cd 100644 --- a/crates/op-host-services/src/mcp_serve/tool_profile_tests.rs +++ b/crates/op-host-services/src/mcp_serve/tool_profile_tests.rs @@ -66,6 +66,7 @@ fn the_denied_set_is_exactly_the_reviewed_list() { // Writes a deck file at a caller-chosen path — denied online for the // same reason save_document is. "export_deck", + "export_frames", "import_html", "import_html_url", "import_svg",