feat(mcp): expose the scene template catalogue to MCP and the CLI

The catalogue shipped in v0.8.3 behind File > New from template, so an
agent could only ever start from a blank frame. The 16:9 deck templates
live in it too, which makes it the entry point to the presentation
workflow rather than a convenience.

Adopting a template is its own EditorCommand instead of an authored
subtree insert: a template's boards and the palette they resolve against
have to land in one transaction, and OkWithCommand carries exactly one
command. AdoptSceneTemplate reuses adopt_template_boards, which already
encodes the decision a host without a document loader needs — take over an
untouched starter page, append anywhere else.

The two exhaustive classifiers both demanded a verdict, as designed: the
command is batchable, and it stays an unsupported bulk write inside a
collaboration session.
This commit is contained in:
Kayshen-X 2026-08-11 07:52:52 +08:00
parent 24a6305cae
commit 507510ee0c
14 changed files with 333 additions and 1 deletions

View file

@ -195,3 +195,38 @@ fn export_deck_rejects_a_node_export_format() {
.to_string();
assert!(error.contains("unsupported deck format"), "{error}");
}
#[test]
fn use_template_accepts_a_bare_id_or_a_flag() {
for argv in [
vec!["use-template", "slide-deck"],
vec!["use-template", "--template", "slide-deck"],
] {
let parsed = parse_args(&args(&argv)).expect("parse use-template");
assert!(matches!(
parsed.command,
Command::UseTemplate { ref template_id } if template_id == "slide-deck"
));
}
assert!(parse_args(&args(&["use-template"]))
.unwrap_err()
.to_string()
.contains("template id"));
}
#[test]
fn templates_filters_are_optional() {
let bare = parse_args(&args(&["templates"])).expect("parse templates");
assert!(matches!(
bare.command,
Command::Templates {
scene: None,
tag: None
}
));
let filtered = parse_args(&args(&["templates", "--scene", "slides"])).expect("parse filter");
assert!(matches!(
filtered.command,
Command::Templates { scene: Some(ref scene), .. } if scene == "slides"
));
}

View file

@ -18,6 +18,7 @@ mod path_args;
mod skill_export_cli;
mod skill_install_cli;
mod skill_install_error;
mod template_cli;
use cli_error::CliError;
@ -129,6 +130,15 @@ fn run(args: &[String]) -> Result<String, CliError> {
Command::ExportDeck { output, format } => {
export_cli::run_export_deck(target_port, &target_token, &output, &format)?
}
Command::Templates { scene, tag } => template_cli::run_templates(
target_port,
&target_token,
scene.as_deref(),
tag.as_deref(),
)?,
Command::UseTemplate { template_id } => {
template_cli::run_use_template(target_port, &target_token, &template_id)?
}
Command::Export {
item_id,
selection: _,
@ -218,6 +228,13 @@ enum Command {
output: String,
format: String,
},
Templates {
scene: Option<String>,
tag: Option<String>,
},
UseTemplate {
template_id: String,
},
}
type Flags = BTreeMap<String, Option<String>>;
@ -343,6 +360,8 @@ fn command_from_positionals(positionals: &[String], flags: &Flags) -> Result<Com
"stop" => Ok(Command::StopMcp),
"export" => export_cli::map_export(flags),
"export-deck" => export_cli::map_export_deck(flags),
"templates" => template_cli::map_templates(flags),
"use-template" => template_cli::map_use_template(flags, positionals),
"skill:export" => Ok(Command::SkillExport {
name: required_pos(
positionals,

View file

@ -0,0 +1,68 @@
//! `op templates` and `op use-template` — the shipped scene-template
//! catalogue, including the 16:9 presentation deck templates.
//!
//! Both are thin passes over the matching MCP tools; the catalogue itself is
//! resolved editor-side so the CLI never carries a second copy of it.
use serde_json::Value;
use crate::cli_error::CliError;
use crate::command_helpers::flag_value;
use crate::mcp_http_cli::{post, tool_call_body};
use crate::{Command, Flags};
pub(crate) fn map_templates(flags: &Flags) -> Result<Command, CliError> {
Ok(Command::Templates {
scene: flag_value(flags, "scene"),
tag: flag_value(flags, "tag"),
})
}
pub(crate) fn map_use_template(flags: &Flags, positionals: &[String]) -> Result<Command, CliError> {
// Accept both `op use-template <id>` and `--template <id>`: the bare
// positional is what a person types, the flag is what a script generates.
// `positionals[0]` is the command word itself, so the id is at index 1.
let template_id = positionals
.get(1)
.cloned()
.or_else(|| flag_value(flags, "template"))
.ok_or_else(|| CliError::usage("a template id is required (op templates lists them)"))?;
Ok(Command::UseTemplate { template_id })
}
pub(crate) fn run_templates(
port: u16,
token: &str,
scene: Option<&str>,
tag: Option<&str>,
) -> Result<String, CliError> {
let mut arguments = serde_json::Map::new();
if let Some(scene) = scene {
arguments.insert("scene".into(), Value::String(scene.into()));
}
if let Some(tag) = tag {
arguments.insert("tag".into(), Value::String(tag.into()));
}
post(
port,
token,
&tool_call_body(
"list_scene_templates",
&Value::Object(arguments).to_string(),
),
)
}
pub(crate) fn run_use_template(
port: u16,
token: &str,
template_id: &str,
) -> Result<String, CliError> {
let mut arguments = serde_json::Map::new();
arguments.insert("templateId".into(), Value::String(template_id.into()));
post(
port,
token,
&tool_call_body("use_scene_template", &Value::Object(arguments).to_string()),
)
}

View file

@ -28,6 +28,12 @@ COMMON COMMANDS:
export the active page's boards as a
presentation deck: PowerPoint, self-
contained HTML, or slide-per-page PDF
op templates [--scene S] [--tag T] list shipped scene templates
(tutorial / comparison / carousel /
slides / …), including 16:9 decks
op use-template <id> start from a scene template: takes
over a blank starter page, otherwise
appends its boards
op get [--type T] [--name N] [--id ID] [--depth N] [--parent P]
op selection get current selection
op insert <json|@file|-> [--parent P] [--page PAGE] [--post-process]

View file

@ -424,6 +424,7 @@ impl EditorCommand {
| C::InsertSubtree { .. }
| C::InsertAuthoredSubtree { .. }
| C::InsertAuthoredSubtreePreservingRoots { .. }
| C::AdoptSceneTemplate { .. }
| C::RefineDesign { .. }
| C::Batch { .. }
| C::ReplaceAllMatchingProperties { .. }

View file

@ -270,6 +270,16 @@ pub enum EditorCommand {
/// or legacy page index.
page_id: Option<String>,
},
/// Bring a shipped scene template into the document by catalogue id.
///
/// The boards and the palette they depend on travel together, which is
/// why this is its own command rather than an authored-subtree insert:
/// a template's variables have to land in the same transaction as its
/// frames, or the boards resolve against a palette that is not there.
AdoptSceneTemplate {
/// Catalogue id, as listed by `scene_template_catalogue`.
template_id: String,
},
/// Run deterministic post-generation cleanup for a layered design
/// root. Unlike most write commands, a valid root with no needed
/// edits is still accepted so `design_refine` can be idempotent.

View file

@ -309,6 +309,23 @@ impl EditorState {
parent_id,
page_id,
} => apply_authored_subtree_on_page(self, nodes, &parent_id, page_id.as_deref(), true),
EditorCommand::AdoptSceneTemplate { template_id } => {
// A missing document is a corrupt or renamed asset rather
// than a user error, and on wasm it can simply not be
// fetched yet; either way there is nothing to apply and the
// document is left untouched.
let Some(source) =
crate::scene_template_catalog::scene_template_document(&template_id)
else {
return Ok(false);
};
let Some(boards) =
crate::scene_template_append::template_boards(source, &template_id)
else {
return Ok(false);
};
self.adopt_template_boards(boards)
}
EditorCommand::RefineDesign {
root_id,
canvas_width,

View file

@ -79,6 +79,7 @@ fn batchable(cmd: &EditorCommand) -> bool {
| C::InsertSubtree { .. }
| C::InsertAuthoredSubtree { .. }
| C::InsertAuthoredSubtreePreservingRoots { .. }
| C::AdoptSceneTemplate { .. }
| C::RefineDesign { .. }
| C::SetVariableScalar { .. }
| C::CreateVariable { .. }

View file

@ -256,3 +256,42 @@ fn variable_references(serialized: &str) -> Vec<String> {
}
found
}
#[test]
fn adopting_a_catalogue_template_by_command_brings_its_boards_and_palette() {
// The command exists because boards and palette must land together:
// applying it has to leave both, or the frames resolve against a
// palette that is not there.
let template = crate::scene_template_catalog::scene_template_catalogue()
.iter()
.find(|template| template.style_guide.is_some())
.expect("catalogue ships at least one template carrying a palette");
let mut state = EditorState::new();
let variables_before = state.doc.variables.as_ref().map_or(0, BTreeMap::len);
let changed = state.apply(crate::EditorCommand::AdoptSceneTemplate {
template_id: template.id.clone(),
});
assert!(changed, "adopting {} changed nothing", template.id);
assert!(
!state.active_children().is_empty(),
"adopting {} left no boards",
template.id
);
assert!(
state.doc.variables.as_ref().map_or(0, BTreeMap::len) > variables_before,
"adopting {} brought no palette",
template.id
);
}
#[test]
fn adopting_an_unknown_template_id_leaves_the_document_alone() {
let mut state = EditorState::new();
let changed = state.apply(crate::EditorCommand::AdoptSceneTemplate {
template_id: "no-such-template".to_owned(),
});
assert!(!changed);
assert!(state.active_children().is_empty());
}

View file

@ -640,6 +640,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!("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));
register_tool!("list_components", list_components_snapshot(doc));
register_tool!("get_component", get_component_snapshot(doc));
@ -791,6 +793,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 scene_template_tools;
use scene_template_tools::{list_scene_templates_snapshot, use_scene_template_snapshot};
#[cfg(test)]
mod codegen_wire_tests;

View file

@ -0,0 +1,120 @@
//! MCP tools `list_scene_templates` and `use_scene_template` — the shipped
//! scene-template catalogue, including the 16:9 deck templates.
//!
//! The catalogue has been in the editor since `v0.8.3` behind File ▸ New from
//! template, so an agent could only ever start from a blank frame. Listing it
//! is what lets one start from a real layout, and the deck templates are the
//! entry point to the whole presentation workflow.
//!
//! `use_scene_template` returns a command rather than mutating: the boards and
//! the palette they resolve against have to land in one transaction, which is
//! exactly what `EditorCommand::AdoptSceneTemplate` gives. On an untouched
//! starter page the template takes the page over; anywhere else it appends to
//! the right of what is already there.
use std::collections::BTreeMap;
use op_editor_core::scene_template_catalog::{scene_template_by_id, scene_template_catalogue};
use op_editor_core::EditorCommand;
use op_mcp::{McpTool, ToolErrorCode, ToolOutcome};
pub struct ListSceneTemplates;
impl McpTool for ListSceneTemplates {
fn name(&self) -> &str {
"list_scene_templates"
}
fn call(&self, args: &BTreeMap<String, String>) -> ToolOutcome {
let scene_filter = args
.get("scene")
.map(|value| value.trim())
.filter(|value| !value.is_empty());
let tag_filter = args
.get("tag")
.map(|value| value.trim())
.filter(|value| !value.is_empty());
let templates: Vec<serde_json::Value> = scene_template_catalogue()
.iter()
.filter(|template| {
scene_filter.is_none_or(|scene| scene.eq_ignore_ascii_case(template.scene.as_str()))
})
.filter(|template| {
tag_filter.is_none_or(|tag| {
template
.tags
.iter()
.any(|candidate| candidate.eq_ignore_ascii_case(tag))
})
})
.map(|template| {
serde_json::json!({
"id": template.id,
"scene": template.scene.as_str(),
"title": template.title_fallback,
"summary": template.summary_fallback,
"tags": template.tags,
"frames": template.frames,
"frameWidth": template.frame_width,
"frameHeight": template.frame_height,
// Absent rather than null when a template carries no
// guide: that is the gate on generating from it, not a
// missing field.
"styleGuide": template.style_guide,
})
})
.collect();
ToolOutcome::OkJson(serde_json::json!({ "templates": templates }).to_string())
}
}
pub struct UseSceneTemplate;
impl McpTool for UseSceneTemplate {
fn name(&self) -> &str {
"use_scene_template"
}
fn call(&self, args: &BTreeMap<String, String>) -> ToolOutcome {
let template_id = match args.get("templateId").map(|value| value.trim()) {
Some(id) if !id.is_empty() => id,
_ => {
return ToolOutcome::Err(
ToolErrorCode::MissingArgument,
"templateId is required — call list_scene_templates for the catalogue".into(),
);
}
};
// Validate here so an unknown id is a named argument error rather
// than a command the host silently applies as a no-op.
let Some(template) = scene_template_by_id(template_id) else {
return ToolOutcome::Err(
ToolErrorCode::InvalidArgument,
format!("unknown template id {template_id:?}"),
);
};
ToolOutcome::OkJsonWithCommand(
serde_json::json!({
"id": template.id,
"title": template.title_fallback,
"frames": template.frames,
"frameWidth": template.frame_width,
"frameHeight": template.frame_height,
})
.to_string(),
EditorCommand::AdoptSceneTemplate {
template_id: template.id.clone(),
},
)
}
}
pub fn list_scene_templates_snapshot() -> ListSceneTemplates {
ListSceneTemplates
}
pub fn use_scene_template_snapshot() -> UseSceneTemplate {
UseSceneTemplate
}

View file

@ -31,6 +31,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":"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}}"#,
r#"{"name":"list_components","description":"List registered components (saved Frames / Groups promoted via Save as Component). Returns count + a `;`-separated record of `name|id` pairs.","inputSchema":{"type":"object","properties":{},"additionalProperties":false}}"#,
r#"{"name":"get_component","description":"Fetch one component by id with detail: name, root node kind, and the subtree's leaf count.","inputSchema":{"type":"object","properties":{"component_id":{"type":"string","description":"positive u64 component id"}},"required":["component_id"]}}"#,

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(),
124,
126,
"tools/list catalog count must match the registered tools — add the new tool to this test"
);
// Production catalog excludes debug tools (we removed the

View file

@ -380,6 +380,16 @@ pub const TOOL_PROFILES: &[ToolProfile] = &[
ToolAccess::Read,
ToolSurface::LocalFilesystem,
),
ToolProfile::new(
"list_scene_templates",
ToolAccess::Read,
ToolSurface::InMemory,
),
ToolProfile::new(
"use_scene_template",
ToolAccess::Write,
ToolSurface::InMemory,
),
ToolProfile::new("export_item", ToolAccess::Read, ToolSurface::InMemory),
ToolProfile::new("export_nodes", ToolAccess::Read, ToolSurface::InMemory),
ToolProfile::new("find_empty_space", ToolAccess::Read, ToolSurface::InMemory),