feat(mcp): expose the style-guide catalogue to MCP and the CLI

get_style_guide answers "give me a guide matching this" and searches the
shipped corpus only, so an agent choosing an asset could neither see what
exists nor reach the DESIGN.md files the user imported — the material most
worth picking was invisible to MCP entirely.

list_style_guides merges both, imports first as the Asset Center paints
them, and keeps them apart by id so an import cannot take a shipped guide's
place. Passing id returns that one guide with its markdown, which is what
closes the loop for imports.

No data moved: the canonical registries already live in op-ai-skills, and
only the swatch colours and summary line were ever widget-layer concerns.
The tool reads the host's imported files, so it is a LocalFilesystem
surface and denied online for the same reason list_theme_presets is.
This commit is contained in:
Kayshen-X 2026-08-11 08:03:39 +08:00
parent 507510ee0c
commit fbff8857f3
12 changed files with 272 additions and 25 deletions

View file

@ -230,3 +230,29 @@ fn templates_filters_are_optional() {
Command::Templates { scene: Some(ref scene), .. } if scene == "slides"
));
}
#[test]
fn styles_takes_a_bare_id_or_filters() {
let listing = parse_args(&args(&["styles"])).expect("parse styles");
assert!(matches!(
listing.command,
Command::Styles {
id: None,
tag: None,
platform: None
}
));
let one = parse_args(&args(&["styles", "user:my-brand"])).expect("parse styles id");
assert!(matches!(
one.command,
Command::Styles { id: Some(ref id), .. } if id == "user:my-brand"
));
let filtered =
parse_args(&args(&["styles", "--platform", "slides"])).expect("parse styles filter");
assert!(matches!(
filtered.command,
Command::Styles { platform: Some(ref platform), .. } if platform == "slides"
));
}

View file

@ -139,6 +139,13 @@ fn run(args: &[String]) -> Result<String, CliError> {
Command::UseTemplate { template_id } => {
template_cli::run_use_template(target_port, &target_token, &template_id)?
}
Command::Styles { id, tag, platform } => template_cli::run_styles(
target_port,
&target_token,
id.as_deref(),
tag.as_deref(),
platform.as_deref(),
)?,
Command::Export {
item_id,
selection: _,
@ -235,6 +242,11 @@ enum Command {
UseTemplate {
template_id: String,
},
Styles {
id: Option<String>,
tag: Option<String>,
platform: Option<String>,
},
}
type Flags = BTreeMap<String, Option<String>>;
@ -362,6 +374,7 @@ fn command_from_positionals(positionals: &[String], flags: &Flags) -> Result<Com
"export-deck" => export_cli::map_export_deck(flags),
"templates" => template_cli::map_templates(flags),
"use-template" => template_cli::map_use_template(flags, positionals),
"styles" => template_cli::map_styles(flags, positionals),
"skill:export" => Ok(Command::SkillExport {
name: required_pos(
positionals,

View file

@ -66,3 +66,36 @@ pub(crate) fn run_use_template(
&tool_call_body("use_scene_template", &Value::Object(arguments).to_string()),
)
}
pub(crate) fn map_styles(flags: &Flags, positionals: &[String]) -> Result<Command, CliError> {
Ok(Command::Styles {
// `positionals[0]` is the command word; a bare id after it selects one
// guide and pulls its markdown down with it.
id: positionals
.get(1)
.cloned()
.or_else(|| flag_value(flags, "id")),
tag: flag_value(flags, "tag"),
platform: flag_value(flags, "platform"),
})
}
pub(crate) fn run_styles(
port: u16,
token: &str,
id: Option<&str>,
tag: Option<&str>,
platform: Option<&str>,
) -> Result<String, CliError> {
let mut arguments = serde_json::Map::new();
for (key, value) in [("id", id), ("tag", tag), ("platform", platform)] {
if let Some(value) = value {
arguments.insert(key.into(), Value::String(value.into()));
}
}
post(
port,
token,
&tool_call_body("list_style_guides", &Value::Object(arguments).to_string()),
)
}

View file

@ -34,6 +34,9 @@ COMMON COMMANDS:
op use-template <id> start from a scene template: takes
over a blank starter page, otherwise
appends its boards
op styles [<id>] [--tag T] [--platform P] list style guides: the shipped
corpus plus your imported DESIGN.md
files; a bare id prints its markdown
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

@ -37,27 +37,27 @@ use op_mcp::{
get_style_guide_tags_snapshot, get_variables_snapshot, get_viewport_snapshot,
group_selected_snapshot, import_svg_snapshot, insert_node_snapshot,
instantiate_component_snapshot, lint_document_snapshot, list_components_snapshot,
list_node_kinds_snapshot, list_pages_snapshot, list_theme_presets_snapshot,
list_variables_snapshot, load_theme_preset_snapshot, move_node_snapshot,
nudge_selected_snapshot, open_document_snapshot, paste_clipboard_snapshot, read_nodes_snapshot,
redo_snapshot, remove_node_effect_snapshot, remove_page_snapshot, rename_component_snapshot,
rename_page_snapshot, rename_variable_snapshot, reorder_page_snapshot,
reorder_selected_snapshot, replace_all_matching_properties_snapshot, replace_node_snapshot,
run_stdio_with_applier, save_document_snapshot, save_theme_preset_snapshot,
search_all_unique_properties_snapshot, selection_snapshot, set_active_axis_value_snapshot,
set_active_page_snapshot, set_active_tool_snapshot, set_design_md_snapshot,
set_ellipse_arc_snapshot, set_node_collapsed_snapshot, set_node_corner_radius_snapshot,
set_node_fill_hex_snapshot, set_node_flip_snapshot, set_node_font_size_snapshot,
set_node_font_weight_snapshot, set_node_hidden_snapshot, set_node_locked_snapshot,
set_node_name_snapshot, set_node_rotation_snapshot, set_node_stroke_hex_snapshot,
set_node_stroke_side_width_snapshot, set_node_stroke_width_snapshot, set_node_text_snapshot,
set_selection_set_snapshot, set_selection_snapshot, set_themes_snapshot,
set_variable_boolean_snapshot, set_variable_color_snapshot, set_variable_number_snapshot,
set_variable_string_snapshot, set_variables_snapshot, set_viewport_snapshot,
snapshot_layout_snapshot, spawn_agents_snapshot, toggle_node_selection_snapshot,
tool_search_snapshot, undo_snapshot, ungroup_selected_snapshot, update_node_snapshot,
upsert_component_snapshot, upsert_screen_snapshot, upsert_variables_snapshot, McpTool,
ToolRegistry,
list_node_kinds_snapshot, list_pages_snapshot, list_style_guides_snapshot,
list_theme_presets_snapshot, list_variables_snapshot, load_theme_preset_snapshot,
move_node_snapshot, nudge_selected_snapshot, open_document_snapshot, paste_clipboard_snapshot,
read_nodes_snapshot, redo_snapshot, remove_node_effect_snapshot, remove_page_snapshot,
rename_component_snapshot, rename_page_snapshot, rename_variable_snapshot,
reorder_page_snapshot, reorder_selected_snapshot, replace_all_matching_properties_snapshot,
replace_node_snapshot, run_stdio_with_applier, save_document_snapshot,
save_theme_preset_snapshot, search_all_unique_properties_snapshot, selection_snapshot,
set_active_axis_value_snapshot, set_active_page_snapshot, set_active_tool_snapshot,
set_design_md_snapshot, set_ellipse_arc_snapshot, set_node_collapsed_snapshot,
set_node_corner_radius_snapshot, set_node_fill_hex_snapshot, set_node_flip_snapshot,
set_node_font_size_snapshot, set_node_font_weight_snapshot, set_node_hidden_snapshot,
set_node_locked_snapshot, set_node_name_snapshot, set_node_rotation_snapshot,
set_node_stroke_hex_snapshot, set_node_stroke_side_width_snapshot,
set_node_stroke_width_snapshot, set_node_text_snapshot, set_selection_set_snapshot,
set_selection_snapshot, set_themes_snapshot, set_variable_boolean_snapshot,
set_variable_color_snapshot, set_variable_number_snapshot, set_variable_string_snapshot,
set_variables_snapshot, set_viewport_snapshot, snapshot_layout_snapshot, spawn_agents_snapshot,
toggle_node_selection_snapshot, tool_search_snapshot, undo_snapshot, ungroup_selected_snapshot,
update_node_snapshot, upsert_component_snapshot, upsert_screen_snapshot,
upsert_variables_snapshot, McpTool, ToolRegistry,
};
#[cfg(feature = "mcp-debug-tools")]
use op_mcp::{
@ -631,6 +631,7 @@ fn rebuild_registry(doc: &EditorState, requested_tool: Option<&str>) -> ToolRegi
register_tool!("export_design_md", export_design_md_snapshot(doc));
register_tool!("get_style_guide_tags", get_style_guide_tags_snapshot());
register_tool!("get_style_guide", get_style_guide_snapshot());
register_tool!("list_style_guides", list_style_guides_snapshot());
register_tool!("get_guidelines", get_guidelines_snapshot());
// Phase 0: always register spawn_agents (validates + returns request result).
// Actual parallel execution is deferred to Phase 3 (Task 3.1).

View file

@ -24,6 +24,7 @@ pub const TOOL_SCHEMAS: &[&str] = &[
r#"{"name":"export_design_md","description":"Export design.md markdown, falling back to best-effort extraction when none is persisted.","inputSchema":{"type":"object","properties":{"filePath":{"type":"string","description":"Optional target .op file path; omit to use the server document"}}}}"#,
r#"{"name":"get_style_guide_tags","description":"Return all available style guide tags for filtering light/dark visual styles.","inputSchema":{"type":"object","properties":{},"additionalProperties":false}}"#,
r#"{"name":"get_style_guide","description":"Return a style guide by name or best tag match. Provide tags array/string, name, and optional platform.","inputSchema":{"type":"object","properties":{"tags":{"type":"array","items":{"type":"string"}},"name":{"type":"string"},"platform":{"type":"string","enum":["webapp","mobile","landing-page","slides"]}}}}"#,
r#"{"name":"list_style_guides","description":"List every style guide the editor can offer: the shipped corpus plus the DESIGN.md files the user imported (imports first). Pass id to fetch one guide with its markdown — get_style_guide reaches the shipped corpus only.","inputSchema":{"type":"object","properties":{"id":{"type":"string","description":"Guide id: a corpus guide's name, or user:<slug> for an import"},"tag":{"type":"string"},"platform":{"type":"string","enum":["webapp","mobile","landing-page","slides"]}}}}"#,
r#"{"name":"get_guidelines","description":"Return OpenPencil product-design guidelines. Default category is guide, which reads topic. category=style resolves an OpenPencil style using flat scalar string args.","inputSchema":{"type":"object","properties":{"category":{"type":"string","enum":["guide","style"],"default":"guide","description":"guide returns the existing topic guideline path; style resolves the style catalog"},"topic":{"type":"string","enum":["web-app","mobile","code-to-design"],"description":"Guide topic for category=guide"},"name":{"type":"string","description":"Style catalog name for category=style"},"colorPalette":{"type":"string","description":"Style palette name for category=style"},"roundness":{"type":"string","description":"Roundness profile for category=style"},"elevation":{"type":"string","description":"Elevation profile for category=style"},"headings":{"type":"string","description":"Heading font family for category=style"},"body":{"type":"string","description":"Body font family for category=style"},"captions":{"type":"string","description":"Caption font family for category=style"},"data":{"type":"string","description":"Data font family for category=style"},"decorativeImagery":{"type":"string","description":"Optional decorative imagery guidance for category=style"}}}}"#,
r#"{"name":"spawn_agents","description":"Split a large design task into parallel subtasks. Each config item gives a prompt, the container node(s) to fill, and the styleguide + guideline NAMES to pass to the subagent (subagents cannot search styleguides). Returns the spawned agent ids. Execution runs the subagents in parallel.","inputSchema":{"type":"object","properties":{"config":{"type":"array","items":{"type":"object","properties":{"prompt":{"type":"string"},"containerNodes":{"type":"array","items":{"type":"string"}},"styleguideName":{"type":"string"},"guidelineNames":{"type":"array","items":{"type":"string"}}},"required":["prompt","styleguideName"]}}},"required":["config"]}}"#,
r#"{"name":"ToolSearch","description":"Discover tools by keyword or exact selection. Use 'select:Name1,Name2' to load specific tools, or a keyword query to search names+descriptions.","inputSchema":{"type":"object","properties":{"query":{"type":"string"},"max_results":{"type":"integer","minimum":1,"default":5}},"required":["query"]}}"#,

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(),
126,
127,
"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,13 @@ pub const TOOL_PROFILES: &[ToolProfile] = &[
ToolAccess::Read,
ToolSurface::LocalFilesystem,
),
// Reads the user's imported DESIGN.md files off the daemon host, not
// just the embedded corpus, so it is not an in-memory surface.
ToolProfile::new(
"list_style_guides",
ToolAccess::Read,
ToolSurface::LocalFilesystem,
),
ToolProfile::new(
"list_scene_templates",
ToolAccess::Read,

View file

@ -70,6 +70,9 @@ fn the_denied_set_is_exactly_the_reviewed_list() {
"import_html_url",
"import_svg",
"import_web_snapshot",
// Reads the host's imported DESIGN.md files, which carry no tenant
// dimension — denied online for the same reason list_theme_presets is.
"list_style_guides",
"list_theme_presets",
"load_theme_preset",
"open_document",

View file

@ -268,7 +268,8 @@ pub use spawn_agents_tool::{spawn_agents_snapshot, SpawnAgents, SpawnSpec};
pub mod tool_search;
pub use editor_state_tool::{get_editor_state_snapshot, GetEditorState};
pub use style_guide_tools::{
get_style_guide_snapshot, get_style_guide_tags_snapshot, GetStyleGuide, GetStyleGuideTags,
get_style_guide_snapshot, get_style_guide_tags_snapshot, list_style_guides_snapshot,
GetStyleGuide, GetStyleGuideTags, ListStyleGuides,
};
pub use style_ops_tools::{
replace_all_matching_properties_snapshot, search_all_unique_properties_snapshot,

View file

@ -3,7 +3,8 @@
use std::collections::BTreeMap;
use op_ai_skills::style_guide::{
select_style_guide, style_guide_registry, Platform, SelectOptions, STYLE_GUIDE_TAGS,
select_style_guide, style_guide_registry, user_style_guides, Platform, SelectOptions,
STYLE_GUIDE_TAGS,
};
use super::{McpTool, ToolErrorCode, ToolOutcome};
@ -79,6 +80,106 @@ impl McpTool for GetStyleGuide {
}
}
/// The Asset Center's Styles tab, as a tool.
///
/// `get_style_guide` answers "give me *a* guide matching this", and only ever
/// from the shipped corpus. Neither half serves an agent choosing an asset: it
/// cannot see what exists, and the user's own imported `DESIGN.md` files —
/// the material most worth picking — were invisible to MCP entirely.
///
/// Imports come first, as they do in the panel: a list where your own material
/// sits below fifty shipped entries is a list you scroll past your own work in.
/// Ids keep the two apart, so an import that names itself after a shipped guide
/// cannot quietly take its place.
///
/// Passing `id` returns that one entry with its markdown, which is what closes
/// the loop for user guides — `get_style_guide` cannot reach them by name.
pub struct ListStyleGuides;
impl McpTool for ListStyleGuides {
fn name(&self) -> &str {
"list_style_guides"
}
fn call(&self, args: &BTreeMap<String, String>) -> ToolOutcome {
let arg = |key: &str| {
args.get(key)
.map(|value| value.trim())
.filter(|value| !value.is_empty())
};
let wanted_id = arg("id");
let tag_filter = arg("tag");
let platform_filter = arg("platform");
let user_guides = user_style_guides();
let mut entries: Vec<serde_json::Value> = Vec::new();
for user in &user_guides {
entries.push(serde_json::json!({
"id": user.id,
"name": user.guide.name,
"platform": user.guide.platform.as_str(),
"tags": user.guide.tags,
"isUser": true,
"swatches": user.swatches,
}));
}
for guide in style_guide_registry() {
entries.push(serde_json::json!({
// A corpus guide is pinned by its bare name; that is its id.
"id": guide.name,
"name": guide.name,
"platform": guide.platform.as_str(),
"tags": guide.tags,
"isUser": false,
}));
}
if let Some(wanted_id) = wanted_id {
let Some(mut entry) = entries
.into_iter()
.find(|entry| entry["id"].as_str() == Some(wanted_id))
else {
return ToolOutcome::Err(
ToolErrorCode::InvalidArgument,
format!("unknown style guide id {wanted_id:?}"),
);
};
let content = user_guides
.iter()
.find(|user| user.id == wanted_id)
.map(|user| user.guide.content.clone())
.or_else(|| {
style_guide_registry()
.iter()
.find(|guide| guide.name == wanted_id)
.map(|guide| guide.content.clone())
});
if let Some(content) = content {
entry["content"] = serde_json::Value::String(content);
}
return ToolOutcome::OkJson(serde_json::json!({ "guides": [entry] }).to_string());
}
entries.retain(|entry| {
let tag_ok = tag_filter.is_none_or(|tag| {
entry["tags"]
.as_array()
.is_some_and(|tags| tags.iter().any(|candidate| candidate == tag))
});
let platform_ok =
platform_filter.is_none_or(|platform| entry["platform"].as_str() == Some(platform));
tag_ok && platform_ok
});
let count = entries.len();
ToolOutcome::OkJson(serde_json::json!({ "guides": entries, "count": count }).to_string())
}
}
pub fn list_style_guides_snapshot() -> ListStyleGuides {
ListStyleGuides
}
pub fn get_style_guide_tags_snapshot() -> GetStyleGuideTags {
GetStyleGuideTags
}

View file

@ -2,7 +2,10 @@
use std::collections::BTreeMap;
use super::{get_style_guide_snapshot, get_style_guide_tags_snapshot, McpTool, ToolOutcome};
use super::{
get_style_guide_snapshot, get_style_guide_tags_snapshot, list_style_guides_snapshot, McpTool,
ToolErrorCode, ToolOutcome,
};
#[test]
fn get_style_guide_tags_returns_light_and_dark_vocab() {
@ -40,3 +43,58 @@ fn get_style_guide_finds_specific_guide_by_name() {
other => panic!("expected guide ok, got {other:?}"),
}
}
#[test]
fn listing_style_guides_covers_the_whole_shipped_corpus() {
let out = match list_style_guides_snapshot().call(&BTreeMap::new()) {
ToolOutcome::OkJson(json) => json,
other => panic!("unexpected outcome: {other:?}"),
};
let value: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
let guides = value["guides"].as_array().expect("guides array");
let corpus = op_ai_skills::style_guide::style_guide_registry().len();
assert!(
guides.len() >= corpus,
"listing dropped corpus entries: {} < {corpus}",
guides.len()
);
assert_eq!(value["count"].as_u64(), Some(guides.len() as u64));
// Every corpus entry is addressable by the id the list hands back.
for guide in op_ai_skills::style_guide::style_guide_registry() {
assert!(
guides
.iter()
.any(|entry| entry["id"].as_str() == Some(guide.name.as_str())),
"{} missing from the listing",
guide.name
);
}
}
#[test]
fn requesting_one_style_guide_by_id_carries_its_markdown() {
let first = &op_ai_skills::style_guide::style_guide_registry()[0];
let mut args = BTreeMap::new();
args.insert("id".to_string(), first.name.clone());
let out = match list_style_guides_snapshot().call(&args) {
ToolOutcome::OkJson(json) => json,
other => panic!("unexpected outcome: {other:?}"),
};
let value: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
let guides = value["guides"].as_array().expect("guides array");
assert_eq!(guides.len(), 1);
assert_eq!(guides[0]["content"].as_str(), Some(first.content.as_str()));
}
#[test]
fn an_unknown_style_guide_id_is_a_named_argument_error() {
let mut args = BTreeMap::new();
args.insert("id".to_string(), "no-such-guide".to_string());
match list_style_guides_snapshot().call(&args) {
ToolOutcome::Err(code, message) => {
assert_eq!(code, ToolErrorCode::InvalidArgument);
assert!(message.contains("no-such-guide"), "{message}");
}
other => panic!("unexpected outcome: {other:?}"),
}
}