feat(mcp): add_page write tool — 29th tool, LLM-driven page CRUD

Append a fresh empty page + switch the active page to it. No
args. Mirrors TS `addPage()`. Applier returns false on
id-space exhaustion at `max_node_id() + 1`.

Wire shape:
  args: {}
  result: { "wrote": "true" }
  command: `McpCommand::AddPage`

Companion to set_active_page (commit 1af880c2): LLMs can now
create + navigate pages without touching the UI. Future patches
can add rename_page / delete_page / duplicate_page to complete
the page CRUD (Document already has the mutators).

Desktop --mcp registry + tools/list schema + exact-count test
updated for 29 tools.
This commit is contained in:
Kayshen-X 2026-05-15 05:13:25 +08:00
parent 750434646d
commit 85ea875765
5 changed files with 40 additions and 9 deletions

View file

@ -26,9 +26,9 @@ 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,
create_component_snapshot, delete_component_snapshot, document_info_snapshot,
get_component_snapshot, instantiate_component_snapshot, list_components_snapshot,
rename_component_snapshot, set_active_page_snapshot,
add_page_snapshot, create_component_snapshot, delete_component_snapshot,
document_info_snapshot, get_component_snapshot, instantiate_component_snapshot,
list_components_snapshot, rename_component_snapshot, set_active_page_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,
@ -163,6 +163,7 @@ fn rebuild_registry(doc: &Document) -> ToolRegistry {
r.register(Box::new(delete_component_snapshot()));
r.register(Box::new(rename_component_snapshot()));
r.register(Box::new(set_active_page_snapshot()));
r.register(Box::new(add_page_snapshot()));
r
}
@ -391,6 +392,7 @@ const TOOL_SCHEMAS: &[&str] = &[
r#"{"name":"delete_component","description":"Remove a component from the registry by id. Live instances already on the page are NOT affected — they're independent clones.","inputSchema":{"type":"object","properties":{"component_id":{"type":"string","description":"positive u64 component id"}},"required":["component_id"]}}"#,
r#"{"name":"rename_component","description":"Rename a registered component. Name must be non-empty / non-whitespace.","inputSchema":{"type":"object","properties":{"component_id":{"type":"string","description":"positive u64 component id"},"name":{"type":"string"}},"required":["component_id","name"]}}"#,
r#"{"name":"set_active_page","description":"Switch which page is the active target for subsequent inserts / batch_design / design_* commands. index is 0-based.","inputSchema":{"type":"object","properties":{"index":{"type":"string","description":"0-based page index"}},"required":["index"]}}"#,
r#"{"name":"add_page","description":"Append a fresh empty page and switch the active page to it. Returns false on id-space exhaustion.","inputSchema":{"type":"object","properties":{},"additionalProperties":false}}"#,
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"]}}"#,
@ -444,7 +446,7 @@ mod tests {
}
#[test]
fn tools_list_response_includes_all_twenty_eight_tools() {
fn tools_list_response_includes_all_twenty_nine_tools() {
let r = tools_list_response("3");
// Exact-count assertion: any tool added without
// updating this test will trip the count first. Codex
@ -453,7 +455,7 @@ mod tests {
// without being added to the list below.
assert_eq!(
TOOL_SCHEMAS.len(),
28,
29,
"tools/list catalog count must match the registered tools — add the new tool to this test"
);
for name in [
@ -470,6 +472,7 @@ mod tests {
"delete_component",
"rename_component",
"set_active_page",
"add_page",
"set_variable_color",
"set_active_axis_value",
"insert_node",

View file

@ -509,6 +509,7 @@ impl Document {
crate::mcp::McpCommand::SetActivePage { index } => {
self.set_active_page(*index as usize)
}
crate::mcp::McpCommand::AddPage => self.add_page().is_some(),
crate::mcp::McpCommand::BatchInsert { items } => {
// Validate EVERY descriptor before any mutation.
// A single bad entry rejects the entire batch so

View file

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

View file

@ -39,9 +39,10 @@ pub use write_tools::{
SetActiveAxisValue, SetVariableColor, UpdateNode,
};
pub use component_tools::{
create_component_snapshot, delete_component_snapshot, instantiate_component_snapshot,
rename_component_snapshot, set_active_page_snapshot, CreateComponent, DeleteComponent,
InstantiateComponent, RenameComponent, SetActivePage,
add_page_snapshot, create_component_snapshot, delete_component_snapshot,
instantiate_component_snapshot, rename_component_snapshot, set_active_page_snapshot,
AddPage, CreateComponent, DeleteComponent, InstantiateComponent, RenameComponent,
SetActivePage,
};
pub use batch_design::{
batch_design_snapshot, design_content_snapshot, design_refine_snapshot,
@ -293,6 +294,11 @@ pub enum McpCommand {
SetActivePage {
index: u32,
},
/// Append a fresh empty page to the document + switch active
/// to it. Mirrors TS `addPage()`. Applier returns false on
/// id-space exhaustion (same guard as the rest of the write
/// tools).
AddPage,
}
/// Wire-friendly value payload for `McpCommand::SetVariableScalar`.

View file

@ -219,3 +219,23 @@ pub fn set_active_page_snapshot() -> SetActivePage {
SetActivePage
}
/// First-party `add_page` tool — append a fresh empty page +
/// switch the active page to it. No args. The applier returns
/// false on id-space exhaustion at `max_node_id() + 1`.
pub struct AddPage;
impl McpTool for AddPage {
fn name(&self) -> &str {
"add_page"
}
fn call(&self, _args: &BTreeMap<String, String>) -> ToolOutcome {
let mut out = BTreeMap::new();
out.insert("wrote".into(), "true".into());
ToolOutcome::OkWithCommand(out, McpCommand::AddPage)
}
}
pub fn add_page_snapshot() -> AddPage {
AddPage
}