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