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:
parent
24a6305cae
commit
507510ee0c
|
|
@ -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"
|
||||
));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
68
crates/op-cli/src/template_cli.rs
Normal file
68
crates/op-cli/src/template_cli.rs
Normal 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()),
|
||||
)
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -424,6 +424,7 @@ impl EditorCommand {
|
|||
| C::InsertSubtree { .. }
|
||||
| C::InsertAuthoredSubtree { .. }
|
||||
| C::InsertAuthoredSubtreePreservingRoots { .. }
|
||||
| C::AdoptSceneTemplate { .. }
|
||||
| C::RefineDesign { .. }
|
||||
| C::Batch { .. }
|
||||
| C::ReplaceAllMatchingProperties { .. }
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ fn batchable(cmd: &EditorCommand) -> bool {
|
|||
| C::InsertSubtree { .. }
|
||||
| C::InsertAuthoredSubtree { .. }
|
||||
| C::InsertAuthoredSubtreePreservingRoots { .. }
|
||||
| C::AdoptSceneTemplate { .. }
|
||||
| C::RefineDesign { .. }
|
||||
| C::SetVariableScalar { .. }
|
||||
| C::CreateVariable { .. }
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
120
crates/op-host-services/src/mcp_serve/scene_template_tools.rs
Normal file
120
crates/op-host-services/src/mcp_serve/scene_template_tools.rs
Normal 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
|
||||
}
|
||||
|
|
@ -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"]}}"#,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Reference in a new issue