feat(mcp): list_components read tool — surface registered components

Surfaces the existing Document.components ComponentLibrary via
MCP so LLM clients can discover what reusable Frame / Group
subtrees the user has saved. 22nd tool in the catalog (was 21).

Wire shape:
  count — total component count
  components — `;`-records of `name|id`, with the standard
    2-level escape on each side (matches list_variables /
    list_pages wire convention).

Closes the read-only MCP surface for components. Instance
insertion via MCP (the matching write tool) requires a JSON
Node descriptor and is the natural follow-up; the data model +
`Document::instantiate_component` already exist.

Desktop --mcp registry + tools/list schema updated; test
renamed to all_twenty_two_tools with exact-count guard intact.
This commit is contained in:
Kayshen-X 2026-05-15 02:55:44 +08:00
parent cf5b300714
commit 83fa496124
3 changed files with 54 additions and 6 deletions

View file

@ -26,7 +26,7 @@ use openpencil_shell_core::document::Document;
use openpencil_shell_core::mcp::{
batch_design_snapshot, copy_node_snapshot, delete_node_snapshot,
design_content_snapshot, design_refine_snapshot, design_skeleton_snapshot,
document_info_snapshot,
document_info_snapshot, list_components_snapshot,
get_active_theme_snapshot, get_node_snapshot, insert_node_snapshot,
list_pages_snapshot, list_variables_snapshot, move_node_snapshot,
replace_node_snapshot, run_stdio_with_applier, selection_snapshot,
@ -139,6 +139,7 @@ fn rebuild_registry(doc: &Document) -> ToolRegistry {
r.register(Box::new(list_pages_snapshot(doc)));
r.register(Box::new(list_variables_snapshot(doc)));
r.register(Box::new(get_active_theme_snapshot(doc)));
r.register(Box::new(list_components_snapshot(doc)));
r.register(Box::new(set_variable_color_snapshot(doc)));
r.register(Box::new(set_active_axis_value_snapshot(doc)));
r.register(Box::new(insert_node_snapshot()));
@ -366,6 +367,7 @@ const TOOL_SCHEMAS: &[&str] = &[
r#"{"name":"list_pages","description":"List page ids + names.","inputSchema":{"type":"object","properties":{},"additionalProperties":false}}"#,
r#"{"name":"list_variables","description":"List design variables with kinds.","inputSchema":{"type":"object","properties":{},"additionalProperties":false}}"#,
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}}"#,
// --- write tools ---
r##"{"name":"set_variable_color","description":"Set a Color-kind variable's value.","inputSchema":{"type":"object","properties":{"name":{"type":"string"},"hex":{"type":"string","description":"#rgb / #rrggbb / #rrggbbaa"}},"required":["name","hex"]}}"##,
r#"{"name":"batch_design","description":"Insert N leaf nodes on the active page in one atomic shot. nodes_json must be a JSON array string of {kind,name,x,y,width,height,fill_hex?} descriptors.","inputSchema":{"type":"object","properties":{"nodes_json":{"type":"string","description":"JSON array of node descriptors"}},"required":["nodes_json"]}}"#,
@ -428,7 +430,7 @@ mod tests {
}
#[test]
fn tools_list_response_includes_all_twenty_one_tools() {
fn tools_list_response_includes_all_twenty_two_tools() {
let r = tools_list_response("3");
// Exact-count assertion: any tool added without
// updating this test will trip the count first. Codex
@ -437,7 +439,7 @@ mod tests {
// without being added to the list below.
assert_eq!(
TOOL_SCHEMAS.len(),
21,
22,
"tools/list catalog count must match the registered tools — add the new tool to this test"
);
for name in [
@ -447,6 +449,7 @@ mod tests {
"list_pages",
"list_variables",
"get_active_theme",
"list_components",
"set_variable_color",
"set_active_axis_value",
"insert_node",

View file

@ -25,9 +25,10 @@ pub mod scalar_vars;
// split. Mirrors the `widgets::*` re-export pattern.
pub use parser::parse_tool_call;
pub use tools::{
document_info_snapshot, get_active_theme_snapshot, get_node_snapshot, list_pages_snapshot,
list_variables_snapshot, selection_snapshot, GetActiveTheme, GetDocumentInfo, GetNode,
GetSelection, ListPages, ListVariables, NodeRecord, VariableRecord,
document_info_snapshot, get_active_theme_snapshot, get_node_snapshot,
list_components_snapshot, list_pages_snapshot, list_variables_snapshot, selection_snapshot,
GetActiveTheme, GetDocumentInfo, GetNode, GetSelection, ListComponents, ListPages,
ListVariables, NodeRecord, VariableRecord,
};
pub use write_tools::{
copy_node_snapshot, delete_node_snapshot, insert_node_snapshot, move_node_snapshot,

View file

@ -523,3 +523,47 @@ pub fn get_active_theme_snapshot(doc: &crate::document::Document) -> GetActiveTh
.collect();
GetActiveTheme { active, options }
}
/// First-party `list_components` tool — reports the document's
/// registered components (saved Frames / Groups promoted via
/// "Save as Component"). LLM clients use this to discover what
/// reusable subtrees they can instantiate (instance insertion is
/// still UI-only in the Rust shell; an MCP write tool to spawn
/// instances is a future patch).
///
/// Wire shape:
/// count — total number of components in the library.
/// components — `;`-records of `name|id` pairs, with the legacy
/// 2-level escape (\;|) on each side. Matches the
/// list_variables / list_pages wire convention so clients
/// can reuse their existing decoder.
pub struct ListComponents {
pub items: Vec<(String, u64)>,
}
impl McpTool for ListComponents {
fn name(&self) -> &str {
"list_components"
}
fn call(&self, _args: &BTreeMap<String, String>) -> ToolOutcome {
let encoded: Vec<String> = self
.items
.iter()
.map(|(name, id)| format!("{}|{}", escape_record_field(name), id))
.collect();
let mut out = BTreeMap::new();
out.insert("count".into(), self.items.len().to_string());
out.insert("components".into(), encoded.join(";"));
ToolOutcome::Ok(out)
}
}
pub fn list_components_snapshot(doc: &crate::document::Document) -> ListComponents {
let items = doc
.components
.components
.iter()
.map(|c| (c.name.clone(), c.id.raw()))
.collect();
ListComponents { items }
}