From 8fa555445d7c20f0755a5f255f31fc8d0c1bcf60 Mon Sep 17 00:00:00 2001 From: Kayshen-X Date: Fri, 15 May 2026 02:58:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(mcp):=20instantiate=5Fcomponent=20write=20?= =?UTF-8?q?tool=20=E2=80=94=20drop=20component=20onto=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the matching write surface to list_components. 23rd tool in the catalog. LLM clients can now (a) list registered components via list_components and (b) drop a clone of any of them onto the active page via instantiate_component. Wire shape: args: { "component_id": "" } result: { "wrote": "true" } command: `McpCommand::InstantiateComponent { component_id }` The apply path routes through the existing `Document::instantiate_component` mutator, which deep-clones the component's root subtree with fresh ids past `max_node_id()`, appends it to the active page, pushes a history snapshot, and selects the new instance root. Returns false when the component id is unknown. Desktop --mcp registry + tools/list schema + exact-count test all updated; handshake test now expects 23 tools. Components panel UI is still pending (a future patch adds a right-rail Components section); the MCP surface is the LLM-driven workflow alternative until then. --- crates/openpencil-desktop/src/mcp_serve.rs | 9 +++-- .../src/document/mcp_apply.rs | 13 ++++++ .../src/document/variables.rs | 3 +- crates/openpencil-shell-core/src/mcp.rs | 16 ++++++-- .../src/mcp/write_tools.rs | 40 +++++++++++++++++++ 5 files changed, 74 insertions(+), 7 deletions(-) diff --git a/crates/openpencil-desktop/src/mcp_serve.rs b/crates/openpencil-desktop/src/mcp_serve.rs index 3630211f8..cedfdab06 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, list_components_snapshot, + document_info_snapshot, instantiate_component_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, @@ -155,6 +155,7 @@ fn rebuild_registry(doc: &Document) -> ToolRegistry { r.register(Box::new(set_variable_number_snapshot(doc))); r.register(Box::new(set_variable_string_snapshot(doc))); r.register(Box::new(set_variable_boolean_snapshot(doc))); + r.register(Box::new(instantiate_component_snapshot())); r } @@ -377,6 +378,7 @@ const TOOL_SCHEMAS: &[&str] = &[ r#"{"name":"set_variable_number","description":"Set a Number-kind variable's value (decimal, may be negative or fractional).","inputSchema":{"type":"object","properties":{"name":{"type":"string"},"value":{"type":"string"}},"required":["name","value"]}}"#, r#"{"name":"set_variable_string","description":"Set a String-kind variable's value (free-form text).","inputSchema":{"type":"object","properties":{"name":{"type":"string"},"value":{"type":"string"}},"required":["name","value"]}}"#, r#"{"name":"set_variable_boolean","description":"Set a Boolean-kind variable's value (\"true\" or \"false\").","inputSchema":{"type":"object","properties":{"name":{"type":"string"},"value":{"type":"string","enum":["true","false"]}},"required":["name","value"]}}"#, + r#"{"name":"instantiate_component","description":"Drop a clone of a registered component's root subtree onto the active page. component_id is the id returned by list_components.","inputSchema":{"type":"object","properties":{"component_id":{"type":"string","description":"positive u64 component id"}},"required":["component_id"]}}"#, r#"{"name":"set_active_axis_value","description":"Pin a theme axis to one of its allowed values.","inputSchema":{"type":"object","properties":{"axis":{"type":"string"},"value":{"type":"string"}},"required":["axis","value"]}}"#, r#"{"name":"insert_node","description":"Create a new leaf node on the active page.","inputSchema":{"type":"object","properties":{"kind":{"type":"string","enum":["frame","group","rect","ellipse","polygon","line","text","path"]},"name":{"type":"string"},"x":{"type":"string"},"y":{"type":"string"},"width":{"type":"string"},"height":{"type":"string"},"fill_hex":{"type":"string"}},"required":["kind","name","x","y","width","height"]}}"#, r#"{"name":"update_node","description":"Patch fields on an existing node. Pass any subset of x/y/width/height/name/fill_hex.","inputSchema":{"type":"object","properties":{"node_id":{"type":"string"},"x":{"type":"string"},"y":{"type":"string"},"width":{"type":"string"},"height":{"type":"string"},"name":{"type":"string"},"fill_hex":{"type":"string"}},"required":["node_id"]}}"#, @@ -430,7 +432,7 @@ mod tests { } #[test] - fn tools_list_response_includes_all_twenty_two_tools() { + fn tools_list_response_includes_all_twenty_three_tools() { let r = tools_list_response("3"); // Exact-count assertion: any tool added without // updating this test will trip the count first. Codex @@ -439,7 +441,7 @@ mod tests { // without being added to the list below. assert_eq!( TOOL_SCHEMAS.len(), - 22, + 23, "tools/list catalog count must match the registered tools — add the new tool to this test" ); for name in [ @@ -450,6 +452,7 @@ mod tests { "list_variables", "get_active_theme", "list_components", + "instantiate_component", "set_variable_color", "set_active_axis_value", "insert_node", diff --git a/crates/openpencil-shell-core/src/document/mcp_apply.rs b/crates/openpencil-shell-core/src/document/mcp_apply.rs index 33e6c18b7..23b5a6058 100644 --- a/crates/openpencil-shell-core/src/document/mcp_apply.rs +++ b/crates/openpencil-shell-core/src/document/mcp_apply.rs @@ -474,6 +474,19 @@ impl Document { } true } + crate::mcp::McpCommand::InstantiateComponent { component_id } => { + let Some(target) = NodeId::new_opt(*component_id) else { + return false; + }; + // Reuse the existing mutator. It handles fresh-id + // allocation, history snapshot, + selection. We + // need a mutable next_id seed; thread one based on + // `max_node_id()`. The mutator advances it. + let Some(mut next) = self.next_node_id_seed() else { + return false; + }; + self.instantiate_component(target, &mut next).is_some() + } crate::mcp::McpCommand::BatchInsert { items } => { // Validate EVERY descriptor before any mutation. // A single bad entry rejects the entire batch so diff --git a/crates/openpencil-shell-core/src/document/variables.rs b/crates/openpencil-shell-core/src/document/variables.rs index 14268f2fa..ac9e7c29f 100644 --- a/crates/openpencil-shell-core/src/document/variables.rs +++ b/crates/openpencil-shell-core/src/document/variables.rs @@ -193,7 +193,8 @@ impl VariableTable { | crate::mcp::McpCommand::MoveNode { .. } | crate::mcp::McpCommand::CopyNode { .. } | crate::mcp::McpCommand::ReplaceNode { .. } - | crate::mcp::McpCommand::BatchInsert { .. } => { + | crate::mcp::McpCommand::BatchInsert { .. } + | crate::mcp::McpCommand::InstantiateComponent { .. } => { // Not VariableTable mutations — Pages-level commands // live on `Document::apply_mcp_command`. Return false // so callers with only a VariableTable handle know diff --git a/crates/openpencil-shell-core/src/mcp.rs b/crates/openpencil-shell-core/src/mcp.rs index 7205cb384..ff6da5571 100644 --- a/crates/openpencil-shell-core/src/mcp.rs +++ b/crates/openpencil-shell-core/src/mcp.rs @@ -31,9 +31,10 @@ pub use tools::{ ListVariables, NodeRecord, VariableRecord, }; pub use write_tools::{ - copy_node_snapshot, delete_node_snapshot, insert_node_snapshot, move_node_snapshot, - replace_node_snapshot, set_active_axis_value_snapshot, set_variable_color_snapshot, - update_node_snapshot, CopyNode, DeleteNode, InsertNode, MoveNode, ReplaceNode, + copy_node_snapshot, delete_node_snapshot, insert_node_snapshot, + instantiate_component_snapshot, move_node_snapshot, replace_node_snapshot, + set_active_axis_value_snapshot, set_variable_color_snapshot, update_node_snapshot, + CopyNode, DeleteNode, InsertNode, InstantiateComponent, MoveNode, ReplaceNode, SetActiveAxisValue, SetVariableColor, UpdateNode, }; pub use batch_design::{ @@ -251,6 +252,15 @@ pub enum McpCommand { name: String, scalar: VariableScalarPayload, }, + /// Instantiate a registered component on the active page. The + /// applier deep-clones the component's root subtree with fresh + /// ids past `max_node_id()` and appends it to the active page's + /// top-level children. Mirrors TS's drag-from-Components-panel + /// insertion. Returns false at apply time when the component + /// id is unknown. + InstantiateComponent { + component_id: u64, + }, } /// Wire-friendly value payload for `McpCommand::SetVariableScalar`. diff --git a/crates/openpencil-shell-core/src/mcp/write_tools.rs b/crates/openpencil-shell-core/src/mcp/write_tools.rs index d3260b9cb..5eb7c3496 100644 --- a/crates/openpencil-shell-core/src/mcp/write_tools.rs +++ b/crates/openpencil-shell-core/src/mcp/write_tools.rs @@ -673,6 +673,46 @@ pub fn set_variable_color_snapshot( SetVariableColor { known_colors } } +/// First-party `instantiate_component` tool — drop a clone of a +/// registered component's root subtree onto the active page. +/// Required arg: `component_id` (the id of the component's root, +/// returned by `list_components`). The applier handles fresh-id +/// allocation + history snapshot. +pub struct InstantiateComponent; + +impl McpTool for InstantiateComponent { + fn name(&self) -> &str { + "instantiate_component" + } + fn call(&self, args: &BTreeMap) -> ToolOutcome { + let Some(raw) = args.get("component_id") else { + return ToolOutcome::Err( + ToolErrorCode::MissingArgument, + "component_id is required (from list_components.components `name|id`)".into(), + ); + }; + let component_id: u64 = match raw.parse() { + Ok(n) if n > 0 => n, + _ => { + return ToolOutcome::Err( + ToolErrorCode::InvalidArgument, + format!("component_id must be a positive u64, got {raw:?}"), + ); + } + }; + let mut out = BTreeMap::new(); + out.insert("wrote".into(), "true".into()); + ToolOutcome::OkWithCommand( + out, + McpCommand::InstantiateComponent { component_id }, + ) + } +} + +pub fn instantiate_component_snapshot() -> InstantiateComponent { + InstantiateComponent +} + /// `#rgb`, `#rrggbb`, `#rrggbbaa` — matches the format /// `VariableTable::parse_hex_color` accepts. Lenient on case; /// requires the leading `#`.