feat(mcp): instantiate_component write tool — drop component onto page

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": "<positive u64 id from list_components>" }
  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.
This commit is contained in:
Kayshen-X 2026-05-15 02:58:53 +08:00
parent 83fa496124
commit 8fa555445d
5 changed files with 74 additions and 7 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, 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",

View file

@ -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

View file

@ -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

View file

@ -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`.

View file

@ -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<String, String>) -> 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 `#`.