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:
parent
8fa555445d
commit
1d5a5cde05
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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 `#`.
|
||||
|
|
|
|||
Loading…
Reference in a new issue