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:
parent
fbff8857f3
commit
b8b1b4ed7e
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
||||
|
|
|
|||
123
crates/op-host-services/src/mcp_serve/export_frames_tool.rs
Normal file
123
crates/op-host-services/src/mcp_serve/export_frames_tool.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
|
|
@ -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}}"#,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue