feat(mcp): create_component write tool — promote node to component

Closes the components MCP lifecycle: list_components (read),
create_component (promote a node to a component),
instantiate_component (drop a clone onto the active page). 24th
tool in the catalog.

Wire shape:
  args: { "node_id": "<positive u64>", "name": "<string>" }
  result: { "wrote": "true" }
  command: `McpCommand::CreateComponent { node_id, name }`

New `Document::create_component_from_node` mutator (peer of
the existing `create_component_from_selected`) takes the target
id explicitly instead of reading `self.selected`, because MCP
sessions have no UI selection state. Both share the same
container-kind guard (Frame / Group only) and replace-on-
duplicate-id semantics via `ComponentLibrary::insert`.

Apply path rejects unknown ids + non-container kinds.
Variables table rejects InstantiateComponent + CreateComponent
in its Pages-level guard (defense in depth).

Desktop --mcp registry + tools/list schema + exact-count test
all updated for 24 tools.

LLM workflow:
1. list_components to see what's registered
2. create_component on a Frame / Group to register it
3. instantiate_component to drop a clone of any registered component
This commit is contained in:
Kayshen-X 2026-05-15 03:02:11 +08:00
parent 8fa555445d
commit 1d5a5cde05
6 changed files with 104 additions and 9 deletions

View file

@ -26,7 +26,8 @@ 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, instantiate_component_snapshot, list_components_snapshot,
create_component_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,
@ -156,6 +157,7 @@ fn rebuild_registry(doc: &Document) -> ToolRegistry {
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.register(Box::new(create_component_snapshot()));
r
}
@ -379,6 +381,7 @@ const TOOL_SCHEMAS: &[&str] = &[
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":"create_component","description":"Promote an existing Frame or Group on the active page to a registered component. Use list_components afterwards to see it, instantiate_component to drop a clone.","inputSchema":{"type":"object","properties":{"node_id":{"type":"string","description":"positive u64 node id"},"name":{"type":"string"}},"required":["node_id","name"]}}"#,
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"]}}"#,
@ -432,7 +435,7 @@ mod tests {
}
#[test]
fn tools_list_response_includes_all_twenty_three_tools() {
fn tools_list_response_includes_all_twenty_four_tools() {
let r = tools_list_response("3");
// Exact-count assertion: any tool added without
// updating this test will trip the count first. Codex
@ -441,7 +444,7 @@ mod tests {
// without being added to the list below.
assert_eq!(
TOOL_SCHEMAS.len(),
23,
24,
"tools/list catalog count must match the registered tools — add the new tool to this test"
);
for name in [
@ -453,6 +456,7 @@ mod tests {
"get_active_theme",
"list_components",
"instantiate_component",
"create_component",
"set_variable_color",
"set_active_axis_value",
"insert_node",

View file

@ -49,6 +49,31 @@ impl Document {
/// id, or None when nothing is selected / selection isn't a
/// container kind. Mirrors TS "Make Component" in the right-click
/// context menu.
/// Like `create_component_from_selected`, but takes the target
/// node id explicitly instead of reading `self.selected`.
/// Used by the MCP `create_component` write tool where there
/// is no UI selection state. Returns the component's id on
/// success (== the source node id), or `None` if the id
/// doesn't resolve or the node isn't a Frame / Group.
pub fn create_component_from_node(
&mut self,
node_id: NodeId,
name: impl Into<String>,
) -> Option<NodeId> {
let page = self.active_page()?;
let node = page.find(node_id)?;
if !matches!(node.kind, super::NodeKind::Frame | super::NodeKind::Group) {
return None;
}
let comp = Component {
id: node_id,
name: name.into(),
root: node.clone(),
};
self.components.insert(comp);
Some(node_id)
}
pub fn create_component_from_selected(&mut self, name: impl Into<String>) -> Option<NodeId> {
if !self.selected.is_real() {
return None;

View file

@ -487,6 +487,13 @@ impl Document {
};
self.instantiate_component(target, &mut next).is_some()
}
crate::mcp::McpCommand::CreateComponent { node_id, name } => {
let Some(target) = NodeId::new_opt(*node_id) else {
return false;
};
self.create_component_from_node(target, name.clone())
.is_some()
}
crate::mcp::McpCommand::BatchInsert { items } => {
// Validate EVERY descriptor before any mutation.
// A single bad entry rejects the entire batch so

View file

@ -194,7 +194,8 @@ impl VariableTable {
| crate::mcp::McpCommand::CopyNode { .. }
| crate::mcp::McpCommand::ReplaceNode { .. }
| crate::mcp::McpCommand::BatchInsert { .. }
| crate::mcp::McpCommand::InstantiateComponent { .. } => {
| crate::mcp::McpCommand::InstantiateComponent { .. }
| crate::mcp::McpCommand::CreateComponent { .. } => {
// Not VariableTable mutations — Pages-level commands
// live on `Document::apply_mcp_command`. Return false
// so callers with only a VariableTable handle know

View file

@ -31,11 +31,12 @@ pub use tools::{
ListVariables, NodeRecord, VariableRecord,
};
pub use write_tools::{
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,
copy_node_snapshot, create_component_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, CreateComponent, DeleteNode, InsertNode,
InstantiateComponent, MoveNode, ReplaceNode, SetActiveAxisValue, SetVariableColor,
UpdateNode,
};
pub use batch_design::{
batch_design_snapshot, design_content_snapshot, design_refine_snapshot,
@ -261,6 +262,15 @@ pub enum McpCommand {
InstantiateComponent {
component_id: u64,
},
/// Promote an existing Frame / Group node to a registered
/// component. `node_id` must resolve on the active page +
/// must be a Frame or Group (the applier rejects other
/// kinds). The component is keyed by the node's id; instances
/// later spawn via `InstantiateComponent`.
CreateComponent {
node_id: u64,
name: String,
},
}
/// Wire-friendly value payload for `McpCommand::SetVariableScalar`.

View file

@ -713,6 +713,54 @@ pub fn instantiate_component_snapshot() -> InstantiateComponent {
InstantiateComponent
}
/// First-party `create_component` tool — promote an existing
/// Frame or Group on the active page to a registered component.
/// Required args: `node_id`, `name`. The applier rejects non-
/// container kinds + unknown ids.
pub struct CreateComponent;
impl McpTool for CreateComponent {
fn name(&self) -> &str {
"create_component"
}
fn call(&self, args: &BTreeMap<String, String>) -> ToolOutcome {
let Some(raw_id) = args.get("node_id") else {
return ToolOutcome::Err(
ToolErrorCode::MissingArgument,
"node_id is required".into(),
);
};
let node_id: u64 = match raw_id.parse() {
Ok(n) if n > 0 => n,
_ => {
return ToolOutcome::Err(
ToolErrorCode::InvalidArgument,
format!("node_id must be a positive u64, got {raw_id:?}"),
);
}
};
let Some(name) = args.get("name") else {
return ToolOutcome::Err(
ToolErrorCode::MissingArgument,
"name is required".into(),
);
};
let mut out = BTreeMap::new();
out.insert("wrote".into(), "true".into());
ToolOutcome::OkWithCommand(
out,
McpCommand::CreateComponent {
node_id,
name: name.clone(),
},
)
}
}
pub fn create_component_snapshot() -> CreateComponent {
CreateComponent
}
/// `#rgb`, `#rrggbb`, `#rrggbbaa` — matches the format
/// `VariableTable::parse_hex_color` accepts. Lenient on case;
/// requires the leading `#`.