feat(mcp): move_node write tool — sixth in the catalog

Sixth MCP write tool, completing the node-lifecycle quartet
(insert + update + delete + move). LLMs can now reparent nodes
across the document tree — from page root to a group, between
groups, or back to the root.

`McpCommand::MoveNode { node_id, target_parent_id }`:
  - `target_parent_id == 0` reparents to the active page root.
    Non-zero ids must resolve to an existing node.
  - Cycle guard at apply time: if target_parent is a descendant
    of source, the move would orphan + cycle the subtree, so
    apply returns false.
  - Same-id reparent (node_id == target_parent_id) is rejected
    at the tool layer with InvalidArgument.

`src/document/mcp_apply.rs`:
  - MoveNode branch on Document::apply_mcp_command. Runs the
    cycle guard via `find_node_in_doc` + `subtree_contains`
    BEFORE detaching, so a rejected cycle leaves state intact.
  - `detach_node(doc, id)` + `detach_from_subtree(vec, id)`
    walkers — locate the node, `vec.remove(idx)` it, return
    the owned Node. Reattach via push to the new parent's
    children.
  - `find_node_in_doc(doc, id)` + `find_in_subtree_ref(slice,
    id)` — immutable variants for the cycle check (can't share
    a mutable borrow with the subsequent detach).
  - `subtree_contains(node, target) -> bool` — recursive scan
    of a node's id + descendants.

`src/document/variables.rs`:
  - VariableTable::apply_mcp_command's "Pages-level commands not
    mine" arm grew MoveNode to keep the exhaustive match.

`src/mcp/write_tools.rs`:
  - `MoveNode` (stateless) + `move_node_snapshot()` factory.
    Validates both node_id (positive u64) + target_parent_id
    (any u64, 0 ⇒ page root) and rejects same-id pair.

Tests (6 added, 342 shell-core total):
  - move_node_validates_args — missing both / missing target /
    node_id=0 / node_id == target → InvalidArgument.
  - move_node_returns_command_with_zero_target_for_page_root —
    target_parent_id=0 carries through unchanged.
  - apply_mcp_command_move_node_reparents_to_page_root — sample
    doc; Title(11) under Frame(10); after move target_parent_id=0
    Title is at page root + Frame no longer carries it.
  - apply_mcp_command_move_node_reparents_to_another_node —
    Title(11) → Button group(12); Title leaves Frame, lives
    under Button.
  - apply_mcp_command_move_node_rejects_cycle — Frame(10) ↓
    Button(12) → MoveNode { node_id: 10, target_parent_id: 12 }
    would cycle. Apply returns false + tree is structurally
    intact (Frame at root, Button as child).
  - apply_mcp_command_move_node_rejects_unknown_id — both
    unknown-source and unknown-target branches.

MCP write surface: set_variable_color, set_active_axis_value,
insert_node, update_node, delete_node, move_node. The remaining
TS pen-mcp catalog: copy_node, replace, batch_design,
design_skeleton.
This commit is contained in:
Kayshen-X 2026-05-14 21:08:35 +08:00
parent 2061a9338a
commit 7e3da464c3
5 changed files with 315 additions and 4 deletions

View file

@ -50,6 +50,66 @@ fn remove_in_subtree(children: &mut Vec<Node>, target: NodeId) -> bool {
false
}
/// Immutable lookup for a node anywhere in the document. Used by
/// the move cycle-check (which needs to walk a subtree without
/// holding a mutable borrow on it).
fn find_node_in_doc(doc: &Document, target: NodeId) -> Option<&Node> {
for page in doc.pages.iter() {
if let Some(node) = find_in_subtree_ref(&page.children, target) {
return Some(node);
}
}
None
}
fn find_in_subtree_ref(children: &[Node], target: NodeId) -> Option<&Node> {
for node in children.iter() {
if node.id == target {
return Some(node);
}
}
for node in children.iter() {
if let Some(found) = find_in_subtree_ref(&node.children, target) {
return Some(found);
}
}
None
}
/// True when `node` or any descendant has id == `target`. Used by
/// move's cycle guard: reparenting source under a descendant of
/// itself would orphan + cycle the subtree.
fn subtree_contains(node: &Node, target: NodeId) -> bool {
if node.id == target {
return true;
}
node.children.iter().any(|c| subtree_contains(c, target))
}
/// Find + detach the node with `target` id from its parent's
/// children vec, returning the owned Node. None when not found.
/// Walks every page recursively.
fn detach_node(doc: &mut Document, target: NodeId) -> Option<Node> {
for page in doc.pages.iter_mut() {
if let Some(node) = detach_from_subtree(&mut page.children, target) {
return Some(node);
}
}
None
}
fn detach_from_subtree(children: &mut Vec<Node>, target: NodeId) -> Option<Node> {
if let Some(idx) = children.iter().position(|n| n.id == target) {
return Some(children.remove(idx));
}
for node in children.iter_mut() {
if let Some(detached) = detach_from_subtree(&mut node.children, target) {
return Some(detached);
}
}
None
}
/// Resolve an MCP `kind` arg into a NodeKind. Accepts the same
/// lowercase strings the read-side tools emit (`frame`, `group`,
/// `rect`, `ellipse`, `polygon`, `line`, `text`, `path`).
@ -193,6 +253,67 @@ impl Document {
}
removed
}
crate::mcp::McpCommand::MoveNode {
node_id,
target_parent_id,
} => {
let Some(source) = NodeId::new_opt(*node_id) else {
return false;
};
// Same-id reparent is a no-op (also: target ==
// source would cycle).
if source.raw() == *target_parent_id {
return false;
}
let target_parent = NodeId::new_opt(*target_parent_id);
// Cycle guard: if target_parent is a descendant of
// source, the move would orphan + cycle the
// subtree. Walk source's subtree before detaching.
if let Some(target_id) = target_parent {
if let Some(src_node) = find_node_in_doc(self, source) {
if subtree_contains(src_node, target_id) {
return false;
}
} else {
return false; // source missing
}
}
// Detach the source from its current parent.
let Some(detached) = detach_node(self, source) else {
return false;
};
// Reattach. None target_parent → active page root.
let attached = match target_parent {
None => {
let active_idx = self.active_page_index;
match self.pages.get_mut(active_idx) {
Some(page) => {
page.children.push(detached);
true
}
None => false,
}
}
Some(pid) => match find_node_mut_in_doc(self, pid) {
Some(parent) => {
parent.children.push(detached);
true
}
None => false,
},
};
if !attached {
// Reattachment failed — silently dropped the
// node. Return false so the caller knows the
// op didn't land; but the document state has
// already changed (detached). Realistically
// this only fires under corrupted target
// states; the cycle check + new_opt + sourceness
// checks above cover the validation paths.
return false;
}
true
}
_ => self.var_table.apply_mcp_command(cmd),
}
}

View file

@ -179,7 +179,8 @@ impl VariableTable {
}
crate::mcp::McpCommand::InsertNode { .. }
| crate::mcp::McpCommand::UpdateNode { .. }
| crate::mcp::McpCommand::DeleteNode { .. } => {
| crate::mcp::McpCommand::DeleteNode { .. }
| crate::mcp::McpCommand::MoveNode { .. } => {
// Not VariableTable mutations — Pages-level commands
// live on `Document::apply_mcp_command`. Return false
// so callers with only a VariableTable handle know

View file

@ -24,9 +24,9 @@ pub use tools::{
GetSelection, ListPages, ListVariables, NodeRecord, VariableRecord,
};
pub use write_tools::{
delete_node_snapshot, insert_node_snapshot, set_active_axis_value_snapshot,
set_variable_color_snapshot, update_node_snapshot, DeleteNode, InsertNode,
SetActiveAxisValue, SetVariableColor, UpdateNode,
delete_node_snapshot, insert_node_snapshot, move_node_snapshot,
set_active_axis_value_snapshot, set_variable_color_snapshot, update_node_snapshot,
DeleteNode, InsertNode, MoveNode, SetActiveAxisValue, SetVariableColor, UpdateNode,
};
/// JSON-RPC-style request id. Strings + integers both supported by
@ -155,6 +155,15 @@ pub enum McpCommand {
/// resolve OR points at a Page root (use page mutators for
/// page deletion).
DeleteNode { node_id: u64 },
/// Reparent a node. `target_parent_id == 0` reparents to the
/// page root of the currently-active page. Non-zero ids must
/// resolve to an existing node; the applier rejects moves
/// where the target would create a cycle (target is a
/// descendant of the moved node).
MoveNode {
node_id: u64,
target_parent_id: u64,
},
}
/// Trait every MCP tool implements. The MCP server walks its

View file

@ -383,6 +383,70 @@ pub fn delete_node_snapshot() -> DeleteNode {
DeleteNode
}
/// First-party `move_node` tool — reparent a node. Required args:
/// node_id, target_parent_id. `target_parent_id == "0"` reparents
/// to the active page root. Non-zero ids must resolve to an
/// existing node; the applier rejects cycles (target descendant
/// of source) at apply time.
pub struct MoveNode;
impl McpTool for MoveNode {
fn name(&self) -> &str {
"move_node"
}
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(raw_target) = args.get("target_parent_id") else {
return ToolOutcome::Err(
ToolErrorCode::MissingArgument,
"target_parent_id is required (0 = page root)".into(),
);
};
let target_parent_id: u64 = match raw_target.parse() {
Ok(n) => n,
Err(_) => {
return ToolOutcome::Err(
ToolErrorCode::InvalidArgument,
format!("target_parent_id must be u64, got {raw_target:?}"),
);
}
};
if node_id == target_parent_id {
return ToolOutcome::Err(
ToolErrorCode::InvalidArgument,
"node_id and target_parent_id must differ".into(),
);
}
let mut out = BTreeMap::new();
out.insert("wrote".into(), "true".into());
ToolOutcome::OkWithCommand(
out,
McpCommand::MoveNode {
node_id,
target_parent_id,
},
)
}
}
pub fn move_node_snapshot() -> MoveNode {
MoveNode
}
pub fn set_active_axis_value_snapshot(
doc: &crate::document::Document,
) -> SetActiveAxisValue {

View file

@ -595,3 +595,119 @@ fn apply_mcp_command_update_node_is_atomic_on_invalid_hex() {
assert_eq!(title.bounds.origin.y, before.origin.y);
assert_eq!(title.name, "Title");
}
#[test]
fn move_node_validates_args() {
let tool = move_node_snapshot();
// Missing both.
match tool.call(&BTreeMap::new()) {
ToolOutcome::Err(code, _) => assert_eq!(code, ToolErrorCode::MissingArgument),
_ => panic!(),
}
// node_id only.
let mut args = BTreeMap::new();
args.insert("node_id".into(), "10".into());
match tool.call(&args) {
ToolOutcome::Err(code, msg) => {
assert_eq!(code, ToolErrorCode::MissingArgument);
assert!(msg.contains("target_parent_id"));
}
_ => panic!(),
}
// node_id == 0 invalid.
args.insert("node_id".into(), "0".into());
args.insert("target_parent_id".into(), "5".into());
match tool.call(&args) {
ToolOutcome::Err(code, _) => assert_eq!(code, ToolErrorCode::InvalidArgument),
_ => panic!(),
}
// node_id == target_parent_id.
args.insert("node_id".into(), "10".into());
args.insert("target_parent_id".into(), "10".into());
match tool.call(&args) {
ToolOutcome::Err(code, _) => assert_eq!(code, ToolErrorCode::InvalidArgument),
_ => panic!(),
}
}
#[test]
fn move_node_returns_command_with_zero_target_for_page_root() {
let tool = move_node_snapshot();
let mut args = BTreeMap::new();
args.insert("node_id".into(), "11".into());
args.insert("target_parent_id".into(), "0".into());
match tool.call(&args) {
ToolOutcome::OkWithCommand(_, McpCommand::MoveNode { node_id, target_parent_id }) => {
assert_eq!(node_id, 11);
assert_eq!(target_parent_id, 0);
}
other => panic!("expected MoveNode, got {other:?}"),
}
}
#[test]
fn apply_mcp_command_move_node_reparents_to_page_root() {
use crate::document::Document;
// Sample doc layout: page → frame(10) → [title(11), button(12) → [rect(13), text(14)]]
let mut doc = Document::sample();
let cmd = McpCommand::MoveNode {
node_id: 11,
target_parent_id: 0, // page root
};
assert!(doc.apply_mcp_command(&cmd));
// Title is now a sibling of Frame at the page root.
let root = &doc.pages[0].children;
assert!(root.iter().any(|n| n.id.raw() == 11), "title at page root");
// Frame no longer carries Title.
let frame = root.iter().find(|n| n.id.raw() == 10).unwrap();
assert!(frame.children.iter().all(|n| n.id.raw() != 11));
}
#[test]
fn apply_mcp_command_move_node_reparents_to_another_node() {
use crate::document::Document;
let mut doc = Document::sample();
// Move Title (id 11) under the Button group (id 12).
let cmd = McpCommand::MoveNode {
node_id: 11,
target_parent_id: 12,
};
assert!(doc.apply_mcp_command(&cmd));
let frame = doc.pages[0].children.iter().find(|n| n.id.raw() == 10).unwrap();
let button = frame.children.iter().find(|n| n.id.raw() == 12).unwrap();
assert!(button.children.iter().any(|n| n.id.raw() == 11), "title under button");
assert!(frame.children.iter().all(|n| n.id.raw() != 11), "title detached from frame");
}
#[test]
fn apply_mcp_command_move_node_rejects_cycle() {
use crate::document::Document;
let mut doc = Document::sample();
// Frame (id 10) contains Button (id 12). Move Frame UNDER Button
// — that would cycle. Apply must reject.
let cmd = McpCommand::MoveNode {
node_id: 10,
target_parent_id: 12,
};
assert!(!doc.apply_mcp_command(&cmd), "cycle move must reject");
// Frame is still at the page root with Button as child.
let root = &doc.pages[0].children;
let frame = root.iter().find(|n| n.id.raw() == 10).unwrap();
assert!(frame.children.iter().any(|n| n.id.raw() == 12));
}
#[test]
fn apply_mcp_command_move_node_rejects_unknown_id() {
use crate::document::Document;
let mut doc = Document::sample();
// Unknown source.
assert!(!doc.apply_mcp_command(&McpCommand::MoveNode {
node_id: 99999,
target_parent_id: 10,
}));
// Unknown target.
assert!(!doc.apply_mcp_command(&McpCommand::MoveNode {
node_id: 11,
target_parent_id: 99999,
}));
}