From 85ea875765fb414d199bf8506d16eeb80dcc42bb Mon Sep 17 00:00:00 2001 From: Kayshen-X Date: Fri, 15 May 2026 05:13:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(mcp):=20add=5Fpage=20write=20tool=20?= =?UTF-8?q?=E2=80=94=2029th=20tool,=20LLM-driven=20page=20CRUD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/openpencil-desktop/src/mcp_serve.rs | 13 +++++++----- .../src/document/mcp_apply.rs | 1 + .../src/document/variables.rs | 3 ++- crates/openpencil-shell-core/src/mcp.rs | 12 ++++++++--- .../src/mcp/component_tools.rs | 20 +++++++++++++++++++ 5 files changed, 40 insertions(+), 9 deletions(-) diff --git a/crates/openpencil-desktop/src/mcp_serve.rs b/crates/openpencil-desktop/src/mcp_serve.rs index 9b4a13d95..cfd80752c 100644 --- a/crates/openpencil-desktop/src/mcp_serve.rs +++ b/crates/openpencil-desktop/src/mcp_serve.rs @@ -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", diff --git a/crates/openpencil-shell-core/src/document/mcp_apply.rs b/crates/openpencil-shell-core/src/document/mcp_apply.rs index 1833e64d4..2213e5c0d 100644 --- a/crates/openpencil-shell-core/src/document/mcp_apply.rs +++ b/crates/openpencil-shell-core/src/document/mcp_apply.rs @@ -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 diff --git a/crates/openpencil-shell-core/src/document/variables.rs b/crates/openpencil-shell-core/src/document/variables.rs index 17dc91413..f600c2bfb 100644 --- a/crates/openpencil-shell-core/src/document/variables.rs +++ b/crates/openpencil-shell-core/src/document/variables.rs @@ -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 diff --git a/crates/openpencil-shell-core/src/mcp.rs b/crates/openpencil-shell-core/src/mcp.rs index ab1005d95..49039f061 100644 --- a/crates/openpencil-shell-core/src/mcp.rs +++ b/crates/openpencil-shell-core/src/mcp.rs @@ -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`. diff --git a/crates/openpencil-shell-core/src/mcp/component_tools.rs b/crates/openpencil-shell-core/src/mcp/component_tools.rs index 320625022..93b51bdcb 100644 --- a/crates/openpencil-shell-core/src/mcp/component_tools.rs +++ b/crates/openpencil-shell-core/src/mcp/component_tools.rs @@ -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) -> ToolOutcome { + let mut out = BTreeMap::new(); + out.insert("wrote".into(), "true".into()); + ToolOutcome::OkWithCommand(out, McpCommand::AddPage) + } +} + +pub fn add_page_snapshot() -> AddPage { + AddPage +} +