From 83fa4961247df8035bd6d343710aa28721d162f2 Mon Sep 17 00:00:00 2001 From: Kayshen-X Date: Fri, 15 May 2026 02:55:44 +0800 Subject: [PATCH] =?UTF-8?q?feat(mcp):=20list=5Fcomponents=20read=20tool=20?= =?UTF-8?q?=E2=80=94=20surface=20registered=20components?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/openpencil-desktop/src/mcp_serve.rs | 9 ++-- crates/openpencil-shell-core/src/mcp.rs | 7 +-- crates/openpencil-shell-core/src/mcp/tools.rs | 44 +++++++++++++++++++ 3 files changed, 54 insertions(+), 6 deletions(-) diff --git a/crates/openpencil-desktop/src/mcp_serve.rs b/crates/openpencil-desktop/src/mcp_serve.rs index 8a965f070..3630211f8 100644 --- a/crates/openpencil-desktop/src/mcp_serve.rs +++ b/crates/openpencil-desktop/src/mcp_serve.rs @@ -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", diff --git a/crates/openpencil-shell-core/src/mcp.rs b/crates/openpencil-shell-core/src/mcp.rs index a4e6c6038..7205cb384 100644 --- a/crates/openpencil-shell-core/src/mcp.rs +++ b/crates/openpencil-shell-core/src/mcp.rs @@ -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, diff --git a/crates/openpencil-shell-core/src/mcp/tools.rs b/crates/openpencil-shell-core/src/mcp/tools.rs index 3d06d933e..67859bde1 100644 --- a/crates/openpencil-shell-core/src/mcp/tools.rs +++ b/crates/openpencil-shell-core/src/mcp/tools.rs @@ -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) -> ToolOutcome { + let encoded: Vec = 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 } +}