feat(mcp): expose batch frame export and the deck board list

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.
This commit is contained in:
Kayshen-X 2026-08-11 08:11:39 +08:00
parent fbff8857f3
commit b8b1b4ed7e
10 changed files with 272 additions and 1 deletions

View file

@ -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"));
}

View file

@ -148,3 +148,46 @@ pub(crate) fn write_export_response(response: &str, output: &Path) -> Result<Str
})
.to_string())
}
pub(crate) fn map_export_frames(flags: &Flags) -> Result<Command, CliError> {
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<String, CliError> {
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()),
)
}

View file

@ -139,6 +139,9 @@ fn run(args: &[String]) -> Result<String, CliError> {
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<String>,
platform: Option<String>,
},
ExportFrames {
output_dir: String,
format: String,
},
}
type Flags = BTreeMap<String, Option<String>>;
@ -375,6 +382,7 @@ fn command_from_positionals(positionals: &[String], flags: &Flags) -> Result<Com
"templates" => 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,

View file

@ -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

View file

@ -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};

View file

@ -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<String, String>) -> 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<serde_json::Value> = 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<String, String>) -> 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(),
}
}

View file

@ -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}}"#,

View file

@ -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);
}

View file

@ -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,

View file

@ -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",